Skip to content

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) - #580

Merged
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend
Aug 23, 2026
Merged

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend)#580
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

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 QuizPanel implementation 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.md

What'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, /submit always at the end), the one error-code → copy module honouring Retry-After, a pure session state machine with persistence (no path out of active discards the attempt; source travels 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), Button link variant + forwardRef + a disabled-primary rule; graph/ConceptNode + ConceptNeighbourhood. The tree's node math is extracted once into lib/graph/nodeStyle.ts and 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 → buildQuizHref with from/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 /learn any more.

Removed: QuizPanel.tsx + test, screens/Quiz.tsx, the old api helpers, the old testids (registry rewritten in docs/frontend-testids.md).

Backend: untouched except QUIZ_GENERATE_RATE_LIMIT / _WINDOW_SEC becoming env-overridable (defaults unchanged, tested) — raised to 1000 in scripts/e2e-up.sh and e2e.yml only, because the in-process limiter is shared by a whole Playwright run.

Verification

Known seams (backend follow-ups, each a TODO(#537-followup) in code)

abandon/discard endpoint · feedback mode as a /config option · per-concept attempt filter · finished-attempt review payload · tutor session seed field · XP/streak in the submit response · question type on the wire · flag persistence · enriched /recommendations · a focus/camera prop on the graph renderers · /wiki still publishes pre-#557 tier ranges.

Note: quiz-product-flows.md / quiz-design-brief.md named 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

AndresL230and others added 30 commits August 22, 2026 15:35
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>
AndresL230and others added 24 commits August 22, 2026 17:18
…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>
…a link (#537 D1)
Follow-up to 3b81d61: B1's review round landed `aria-pressed` on the
proposal card's "adjust" control after the registry was written.
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>
)
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingd966da6Commit Preview URL

Branch Preview URL
Aug 23 2026, 06:11 AM

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Too 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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 597f6386-239b-4759-9595-7f62375d8d31

📥 Commits

Reviewing files that changed from the base of the PR and between 7863210 and d966da6.

📒 Files selected for processing (115)
  • .github/workflows/e2e.yml
  • backend/.env.example
  • backend/services/quiz_config.py
  • backend/tests/test_quiz_cost_observability_f.py
  • docs/e2e-exploration.md
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/graph.spec.ts
  • frontend/e2e/quiz-errors.spec.ts
  • frontend/e2e/quiz-integration.spec.ts
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/quiz.ts
  • frontend/e2e/support/quizStack.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/playwright.config.ts
  • frontend/src/app/(shell)/notetaker/page.generateQuiz.test.tsx
  • frontend/src/app/(shell)/notetaker/page.testmode.test.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/app/(shell)/quiz/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/Dialog.tsx
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.test.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.tsx
  • frontend/src/components/graph/ConceptNode.test.tsx
  • frontend/src/components/graph/ConceptNode.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/__fixtures__/knowledgeGraph2D.golden.json
  • frontend/src/components/quiz/QuizScreen.test.tsx
  • frontend/src/components/quiz/QuizScreen.tsx
  • frontend/src/components/quiz/home/AdjustDialog.tsx
  • frontend/src/components/quiz/home/ConceptDialog.tsx
  • frontend/src/components/quiz/home/PickList.tsx
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/components/quiz/home/QuizSettings.tsx
  • frontend/src/components/quiz/home/accent.ts
  • frontend/src/components/quiz/home/home.css
  • frontend/src/components/quiz/index.ts
  • frontend/src/components/quiz/question/AskPanel.test.tsx
  • frontend/src/components/quiz/question/AskPanel.tsx
  • frontend/src/components/quiz/question/LeaveDialog.tsx
  • frontend/src/components/quiz/question/QuizQuestion.test.tsx
  • frontend/src/components/quiz/question/QuizQuestion.tsx
  • frontend/src/components/quiz/question/question.css
  • frontend/src/components/quiz/quiz.css
  • frontend/src/components/quiz/results/MissedList.tsx
  • frontend/src/components/quiz/results/QuizResults.test.tsx
  • frontend/src/components/quiz/results/QuizResults.tsx
  • frontend/src/components/quiz/results/results.css
  • frontend/src/components/screens/Dashboard.quiz.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Tree.quiz.test.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/components/ui/AnswerOption.test.tsx
  • frontend/src/components/ui/AnswerOption.tsx
  • frontend/src/components/ui/Button.test.tsx
  • frontend/src/components/ui/Button.tsx
  • frontend/src/components/ui/EmptyState.test.tsx
  • frontend/src/components/ui/EmptyState.tsx
  • frontend/src/components/ui/InlineBanner.test.tsx
  • frontend/src/components/ui/InlineBanner.tsx
  • frontend/src/components/ui/ProgressDots.test.tsx
  • frontend/src/components/ui/ProgressDots.tsx
  • frontend/src/components/ui/SegmentedControl.test.tsx
  • frontend/src/components/ui/SegmentedControl.tsx
  • frontend/src/components/ui/Sheet.test.tsx
  • frontend/src/components/ui/Sheet.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.test.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx
  • frontend/src/components/ui/__fixtures__/quizPrimitivesGallery.css
  • frontend/src/components/ui/index.ts
  • frontend/src/lib/api.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/errorMessage.test.ts
  • frontend/src/lib/errorMessage.ts
  • frontend/src/lib/graph/neighbourhood.test.ts
  • frontend/src/lib/graph/neighbourhood.ts
  • frontend/src/lib/graph/nodeStyle.test.ts
  • frontend/src/lib/graph/nodeStyle.ts
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/exits.test.ts
  • frontend/src/lib/quiz/exits.ts
  • frontend/src/lib/quiz/machine.test.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/prefs.ts
  • frontend/src/lib/quiz/proposals.test.ts
  • frontend/src/lib/quiz/proposals.ts
  • frontend/src/lib/quiz/relativeTime.test.ts
  • frontend/src/lib/quiz/relativeTime.ts
  • frontend/src/lib/quiz/session.test.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/source.test.ts
  • frontend/src/lib/quiz/source.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useGamificationDelta.ts
  • frontend/src/lib/quiz/useQuizConfig.test.ts
  • frontend/src/lib/quiz/useQuizConfig.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts
  • frontend/src/lib/quiz/useQuizSession.ts
  • frontend/src/lib/quizSelection.ts
  • scripts/e2e-up.sh
  • supabase/snippets/.gitkeep

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

function isHrefAction(a: EmptyStateProps["action"]): a is { label: string; href: string } {
return (
typeof a === "object" &&
a !== null &&
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

Updates to Preview Branch (feat/537-quiz-frontend) ↗︎

DeploymentsStatusUpdated
DatabaseSun, 23 Aug 2026 06:10:30 UTC
ServicesSun, 23 Aug 2026 06:10:30 UTC
APIsSun, 23 Aug 2026 06:10:30 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

TasksStatusUpdated
ConfigurationsSun, 23 Aug 2026 06:10:38 UTC
MigrationsSun, 23 Aug 2026 06:10:42 UTC
SeedingSun, 23 Aug 2026 06:10:42 UTC
Edge FunctionsSun, 23 Aug 2026 06:10:42 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

@AndresL230
AndresL230 merged commit 7097c09 into mainAug 23, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) by AndresL230 · Pull Request #580 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) - #580

Merged
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend
Aug 23, 2026
Merged

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend)#580
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

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 QuizPanel implementation 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.md

What'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, /submit always at the end), the one error-code → copy module honouring Retry-After, a pure session state machine with persistence (no path out of active discards the attempt; source travels 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), Button link variant + forwardRef + a disabled-primary rule; graph/ConceptNode + ConceptNeighbourhood. The tree's node math is extracted once into lib/graph/nodeStyle.ts and 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 → buildQuizHref with from/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 /learn any more.

Removed: QuizPanel.tsx + test, screens/Quiz.tsx, the old api helpers, the old testids (registry rewritten in docs/frontend-testids.md).

Backend: untouched except QUIZ_GENERATE_RATE_LIMIT / _WINDOW_SEC becoming env-overridable (defaults unchanged, tested) — raised to 1000 in scripts/e2e-up.sh and e2e.yml only, because the in-process limiter is shared by a whole Playwright run.

Verification

Known seams (backend follow-ups, each a TODO(#537-followup) in code)

abandon/discard endpoint · feedback mode as a /config option · per-concept attempt filter · finished-attempt review payload · tutor session seed field · XP/streak in the submit response · question type on the wire · flag persistence · enriched /recommendations · a focus/camera prop on the graph renderers · /wiki still publishes pre-#557 tier ranges.

Note: quiz-product-flows.md / quiz-design-brief.md named 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

AndresL230and others added 30 commits August 22, 2026 15:35
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>
AndresL230and others added 24 commits August 22, 2026 17:18
…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>
…a link (#537 D1)
Follow-up to 3b81d61: B1's review round landed `aria-pressed` on the
proposal card's "adjust" control after the registry was written.
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>
)
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingd966da6Commit Preview URL

Branch Preview URL
Aug 23 2026, 06:11 AM

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Too 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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 597f6386-239b-4759-9595-7f62375d8d31

📥 Commits

Reviewing files that changed from the base of the PR and between 7863210 and d966da6.

📒 Files selected for processing (115)
  • .github/workflows/e2e.yml
  • backend/.env.example
  • backend/services/quiz_config.py
  • backend/tests/test_quiz_cost_observability_f.py
  • docs/e2e-exploration.md
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/graph.spec.ts
  • frontend/e2e/quiz-errors.spec.ts
  • frontend/e2e/quiz-integration.spec.ts
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/quiz.ts
  • frontend/e2e/support/quizStack.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/playwright.config.ts
  • frontend/src/app/(shell)/notetaker/page.generateQuiz.test.tsx
  • frontend/src/app/(shell)/notetaker/page.testmode.test.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/app/(shell)/quiz/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/Dialog.tsx
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.test.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.tsx
  • frontend/src/components/graph/ConceptNode.test.tsx
  • frontend/src/components/graph/ConceptNode.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/__fixtures__/knowledgeGraph2D.golden.json
  • frontend/src/components/quiz/QuizScreen.test.tsx
  • frontend/src/components/quiz/QuizScreen.tsx
  • frontend/src/components/quiz/home/AdjustDialog.tsx
  • frontend/src/components/quiz/home/ConceptDialog.tsx
  • frontend/src/components/quiz/home/PickList.tsx
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/components/quiz/home/QuizSettings.tsx
  • frontend/src/components/quiz/home/accent.ts
  • frontend/src/components/quiz/home/home.css
  • frontend/src/components/quiz/index.ts
  • frontend/src/components/quiz/question/AskPanel.test.tsx
  • frontend/src/components/quiz/question/AskPanel.tsx
  • frontend/src/components/quiz/question/LeaveDialog.tsx
  • frontend/src/components/quiz/question/QuizQuestion.test.tsx
  • frontend/src/components/quiz/question/QuizQuestion.tsx
  • frontend/src/components/quiz/question/question.css
  • frontend/src/components/quiz/quiz.css
  • frontend/src/components/quiz/results/MissedList.tsx
  • frontend/src/components/quiz/results/QuizResults.test.tsx
  • frontend/src/components/quiz/results/QuizResults.tsx
  • frontend/src/components/quiz/results/results.css
  • frontend/src/components/screens/Dashboard.quiz.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Tree.quiz.test.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/components/ui/AnswerOption.test.tsx
  • frontend/src/components/ui/AnswerOption.tsx
  • frontend/src/components/ui/Button.test.tsx
  • frontend/src/components/ui/Button.tsx
  • frontend/src/components/ui/EmptyState.test.tsx
  • frontend/src/components/ui/EmptyState.tsx
  • frontend/src/components/ui/InlineBanner.test.tsx
  • frontend/src/components/ui/InlineBanner.tsx
  • frontend/src/components/ui/ProgressDots.test.tsx
  • frontend/src/components/ui/ProgressDots.tsx
  • frontend/src/components/ui/SegmentedControl.test.tsx
  • frontend/src/components/ui/SegmentedControl.tsx
  • frontend/src/components/ui/Sheet.test.tsx
  • frontend/src/components/ui/Sheet.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.test.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx
  • frontend/src/components/ui/__fixtures__/quizPrimitivesGallery.css
  • frontend/src/components/ui/index.ts
  • frontend/src/lib/api.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/errorMessage.test.ts
  • frontend/src/lib/errorMessage.ts
  • frontend/src/lib/graph/neighbourhood.test.ts
  • frontend/src/lib/graph/neighbourhood.ts
  • frontend/src/lib/graph/nodeStyle.test.ts
  • frontend/src/lib/graph/nodeStyle.ts
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/exits.test.ts
  • frontend/src/lib/quiz/exits.ts
  • frontend/src/lib/quiz/machine.test.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/prefs.ts
  • frontend/src/lib/quiz/proposals.test.ts
  • frontend/src/lib/quiz/proposals.ts
  • frontend/src/lib/quiz/relativeTime.test.ts
  • frontend/src/lib/quiz/relativeTime.ts
  • frontend/src/lib/quiz/session.test.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/source.test.ts
  • frontend/src/lib/quiz/source.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useGamificationDelta.ts
  • frontend/src/lib/quiz/useQuizConfig.test.ts
  • frontend/src/lib/quiz/useQuizConfig.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts
  • frontend/src/lib/quiz/useQuizSession.ts
  • frontend/src/lib/quizSelection.ts
  • scripts/e2e-up.sh
  • supabase/snippets/.gitkeep

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

function isHrefAction(a: EmptyStateProps["action"]): a is { label: string; href: string } {
return (
typeof a === "object" &&
a !== null &&
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

Updates to Preview Branch (feat/537-quiz-frontend) ↗︎

DeploymentsStatusUpdated
DatabaseSun, 23 Aug 2026 06:10:30 UTC
ServicesSun, 23 Aug 2026 06:10:30 UTC
APIsSun, 23 Aug 2026 06:10:30 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

TasksStatusUpdated
ConfigurationsSun, 23 Aug 2026 06:10:38 UTC
MigrationsSun, 23 Aug 2026 06:10:42 UTC
SeedingSun, 23 Aug 2026 06:10:42 UTC
Edge FunctionsSun, 23 Aug 2026 06:10:42 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

@AndresL230
AndresL230 merged commit 7097c09 into mainAug 23, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) by AndresL230 · Pull Request #580 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) - #580

Merged
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend
Aug 23, 2026
Merged

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend)#580
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

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 QuizPanel implementation 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.md

What'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, /submit always at the end), the one error-code → copy module honouring Retry-After, a pure session state machine with persistence (no path out of active discards the attempt; source travels 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), Button link variant + forwardRef + a disabled-primary rule; graph/ConceptNode + ConceptNeighbourhood. The tree's node math is extracted once into lib/graph/nodeStyle.ts and 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 → buildQuizHref with from/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 /learn any more.

Removed: QuizPanel.tsx + test, screens/Quiz.tsx, the old api helpers, the old testids (registry rewritten in docs/frontend-testids.md).

Backend: untouched except QUIZ_GENERATE_RATE_LIMIT / _WINDOW_SEC becoming env-overridable (defaults unchanged, tested) — raised to 1000 in scripts/e2e-up.sh and e2e.yml only, because the in-process limiter is shared by a whole Playwright run.

Verification

Known seams (backend follow-ups, each a TODO(#537-followup) in code)

abandon/discard endpoint · feedback mode as a /config option · per-concept attempt filter · finished-attempt review payload · tutor session seed field · XP/streak in the submit response · question type on the wire · flag persistence · enriched /recommendations · a focus/camera prop on the graph renderers · /wiki still publishes pre-#557 tier ranges.

Note: quiz-product-flows.md / quiz-design-brief.md named 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

AndresL230and others added 30 commits August 22, 2026 15:35
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>
AndresL230and others added 24 commits August 22, 2026 17:18
…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>
…a link (#537 D1)
Follow-up to 3b81d61: B1's review round landed `aria-pressed` on the
proposal card's "adjust" control after the registry was written.
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>
)
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingd966da6Commit Preview URL

Branch Preview URL
Aug 23 2026, 06:11 AM

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Too 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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 597f6386-239b-4759-9595-7f62375d8d31

📥 Commits

Reviewing files that changed from the base of the PR and between 7863210 and d966da6.

📒 Files selected for processing (115)
  • .github/workflows/e2e.yml
  • backend/.env.example
  • backend/services/quiz_config.py
  • backend/tests/test_quiz_cost_observability_f.py
  • docs/e2e-exploration.md
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/graph.spec.ts
  • frontend/e2e/quiz-errors.spec.ts
  • frontend/e2e/quiz-integration.spec.ts
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/quiz.ts
  • frontend/e2e/support/quizStack.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/playwright.config.ts
  • frontend/src/app/(shell)/notetaker/page.generateQuiz.test.tsx
  • frontend/src/app/(shell)/notetaker/page.testmode.test.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/app/(shell)/quiz/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/Dialog.tsx
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.test.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.tsx
  • frontend/src/components/graph/ConceptNode.test.tsx
  • frontend/src/components/graph/ConceptNode.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/__fixtures__/knowledgeGraph2D.golden.json
  • frontend/src/components/quiz/QuizScreen.test.tsx
  • frontend/src/components/quiz/QuizScreen.tsx
  • frontend/src/components/quiz/home/AdjustDialog.tsx
  • frontend/src/components/quiz/home/ConceptDialog.tsx
  • frontend/src/components/quiz/home/PickList.tsx
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/components/quiz/home/QuizSettings.tsx
  • frontend/src/components/quiz/home/accent.ts
  • frontend/src/components/quiz/home/home.css
  • frontend/src/components/quiz/index.ts
  • frontend/src/components/quiz/question/AskPanel.test.tsx
  • frontend/src/components/quiz/question/AskPanel.tsx
  • frontend/src/components/quiz/question/LeaveDialog.tsx
  • frontend/src/components/quiz/question/QuizQuestion.test.tsx
  • frontend/src/components/quiz/question/QuizQuestion.tsx
  • frontend/src/components/quiz/question/question.css
  • frontend/src/components/quiz/quiz.css
  • frontend/src/components/quiz/results/MissedList.tsx
  • frontend/src/components/quiz/results/QuizResults.test.tsx
  • frontend/src/components/quiz/results/QuizResults.tsx
  • frontend/src/components/quiz/results/results.css
  • frontend/src/components/screens/Dashboard.quiz.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Tree.quiz.test.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/components/ui/AnswerOption.test.tsx
  • frontend/src/components/ui/AnswerOption.tsx
  • frontend/src/components/ui/Button.test.tsx
  • frontend/src/components/ui/Button.tsx
  • frontend/src/components/ui/EmptyState.test.tsx
  • frontend/src/components/ui/EmptyState.tsx
  • frontend/src/components/ui/InlineBanner.test.tsx
  • frontend/src/components/ui/InlineBanner.tsx
  • frontend/src/components/ui/ProgressDots.test.tsx
  • frontend/src/components/ui/ProgressDots.tsx
  • frontend/src/components/ui/SegmentedControl.test.tsx
  • frontend/src/components/ui/SegmentedControl.tsx
  • frontend/src/components/ui/Sheet.test.tsx
  • frontend/src/components/ui/Sheet.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.test.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx
  • frontend/src/components/ui/__fixtures__/quizPrimitivesGallery.css
  • frontend/src/components/ui/index.ts
  • frontend/src/lib/api.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/errorMessage.test.ts
  • frontend/src/lib/errorMessage.ts
  • frontend/src/lib/graph/neighbourhood.test.ts
  • frontend/src/lib/graph/neighbourhood.ts
  • frontend/src/lib/graph/nodeStyle.test.ts
  • frontend/src/lib/graph/nodeStyle.ts
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/exits.test.ts
  • frontend/src/lib/quiz/exits.ts
  • frontend/src/lib/quiz/machine.test.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/prefs.ts
  • frontend/src/lib/quiz/proposals.test.ts
  • frontend/src/lib/quiz/proposals.ts
  • frontend/src/lib/quiz/relativeTime.test.ts
  • frontend/src/lib/quiz/relativeTime.ts
  • frontend/src/lib/quiz/session.test.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/source.test.ts
  • frontend/src/lib/quiz/source.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useGamificationDelta.ts
  • frontend/src/lib/quiz/useQuizConfig.test.ts
  • frontend/src/lib/quiz/useQuizConfig.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts
  • frontend/src/lib/quiz/useQuizSession.ts
  • frontend/src/lib/quizSelection.ts
  • scripts/e2e-up.sh
  • supabase/snippets/.gitkeep

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

function isHrefAction(a: EmptyStateProps["action"]): a is { label: string; href: string } {
return (
typeof a === "object" &&
a !== null &&
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

Updates to Preview Branch (feat/537-quiz-frontend) ↗︎

DeploymentsStatusUpdated
DatabaseSun, 23 Aug 2026 06:10:30 UTC
ServicesSun, 23 Aug 2026 06:10:30 UTC
APIsSun, 23 Aug 2026 06:10:30 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

TasksStatusUpdated
ConfigurationsSun, 23 Aug 2026 06:10:38 UTC
MigrationsSun, 23 Aug 2026 06:10:42 UTC
SeedingSun, 23 Aug 2026 06:10:42 UTC
Edge FunctionsSun, 23 Aug 2026 06:10:42 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

@AndresL230
AndresL230 merged commit 7097c09 into mainAug 23, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) by AndresL230 · Pull Request #580 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) - #580

Merged
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend
Aug 23, 2026
Merged

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend)#580
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

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 QuizPanel implementation 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.md

What'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, /submit always at the end), the one error-code → copy module honouring Retry-After, a pure session state machine with persistence (no path out of active discards the attempt; source travels 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), Button link variant + forwardRef + a disabled-primary rule; graph/ConceptNode + ConceptNeighbourhood. The tree's node math is extracted once into lib/graph/nodeStyle.ts and 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 → buildQuizHref with from/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 /learn any more.

Removed: QuizPanel.tsx + test, screens/Quiz.tsx, the old api helpers, the old testids (registry rewritten in docs/frontend-testids.md).

Backend: untouched except QUIZ_GENERATE_RATE_LIMIT / _WINDOW_SEC becoming env-overridable (defaults unchanged, tested) — raised to 1000 in scripts/e2e-up.sh and e2e.yml only, because the in-process limiter is shared by a whole Playwright run.

Verification

Known seams (backend follow-ups, each a TODO(#537-followup) in code)

abandon/discard endpoint · feedback mode as a /config option · per-concept attempt filter · finished-attempt review payload · tutor session seed field · XP/streak in the submit response · question type on the wire · flag persistence · enriched /recommendations · a focus/camera prop on the graph renderers · /wiki still publishes pre-#557 tier ranges.

Note: quiz-product-flows.md / quiz-design-brief.md named 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

AndresL230and others added 30 commits August 22, 2026 15:35
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>
AndresL230and others added 24 commits August 22, 2026 17:18
…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>
…a link (#537 D1)
Follow-up to 3b81d61: B1's review round landed `aria-pressed` on the
proposal card's "adjust" control after the registry was written.
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>
)
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingd966da6Commit Preview URL

Branch Preview URL
Aug 23 2026, 06:11 AM

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Too 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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 597f6386-239b-4759-9595-7f62375d8d31

📥 Commits

Reviewing files that changed from the base of the PR and between 7863210 and d966da6.

📒 Files selected for processing (115)
  • .github/workflows/e2e.yml
  • backend/.env.example
  • backend/services/quiz_config.py
  • backend/tests/test_quiz_cost_observability_f.py
  • docs/e2e-exploration.md
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/graph.spec.ts
  • frontend/e2e/quiz-errors.spec.ts
  • frontend/e2e/quiz-integration.spec.ts
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/quiz.ts
  • frontend/e2e/support/quizStack.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/playwright.config.ts
  • frontend/src/app/(shell)/notetaker/page.generateQuiz.test.tsx
  • frontend/src/app/(shell)/notetaker/page.testmode.test.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/app/(shell)/quiz/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/Dialog.tsx
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.test.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.tsx
  • frontend/src/components/graph/ConceptNode.test.tsx
  • frontend/src/components/graph/ConceptNode.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/__fixtures__/knowledgeGraph2D.golden.json
  • frontend/src/components/quiz/QuizScreen.test.tsx
  • frontend/src/components/quiz/QuizScreen.tsx
  • frontend/src/components/quiz/home/AdjustDialog.tsx
  • frontend/src/components/quiz/home/ConceptDialog.tsx
  • frontend/src/components/quiz/home/PickList.tsx
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/components/quiz/home/QuizSettings.tsx
  • frontend/src/components/quiz/home/accent.ts
  • frontend/src/components/quiz/home/home.css
  • frontend/src/components/quiz/index.ts
  • frontend/src/components/quiz/question/AskPanel.test.tsx
  • frontend/src/components/quiz/question/AskPanel.tsx
  • frontend/src/components/quiz/question/LeaveDialog.tsx
  • frontend/src/components/quiz/question/QuizQuestion.test.tsx
  • frontend/src/components/quiz/question/QuizQuestion.tsx
  • frontend/src/components/quiz/question/question.css
  • frontend/src/components/quiz/quiz.css
  • frontend/src/components/quiz/results/MissedList.tsx
  • frontend/src/components/quiz/results/QuizResults.test.tsx
  • frontend/src/components/quiz/results/QuizResults.tsx
  • frontend/src/components/quiz/results/results.css
  • frontend/src/components/screens/Dashboard.quiz.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Tree.quiz.test.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/components/ui/AnswerOption.test.tsx
  • frontend/src/components/ui/AnswerOption.tsx
  • frontend/src/components/ui/Button.test.tsx
  • frontend/src/components/ui/Button.tsx
  • frontend/src/components/ui/EmptyState.test.tsx
  • frontend/src/components/ui/EmptyState.tsx
  • frontend/src/components/ui/InlineBanner.test.tsx
  • frontend/src/components/ui/InlineBanner.tsx
  • frontend/src/components/ui/ProgressDots.test.tsx
  • frontend/src/components/ui/ProgressDots.tsx
  • frontend/src/components/ui/SegmentedControl.test.tsx
  • frontend/src/components/ui/SegmentedControl.tsx
  • frontend/src/components/ui/Sheet.test.tsx
  • frontend/src/components/ui/Sheet.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.test.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx
  • frontend/src/components/ui/__fixtures__/quizPrimitivesGallery.css
  • frontend/src/components/ui/index.ts
  • frontend/src/lib/api.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/errorMessage.test.ts
  • frontend/src/lib/errorMessage.ts
  • frontend/src/lib/graph/neighbourhood.test.ts
  • frontend/src/lib/graph/neighbourhood.ts
  • frontend/src/lib/graph/nodeStyle.test.ts
  • frontend/src/lib/graph/nodeStyle.ts
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/exits.test.ts
  • frontend/src/lib/quiz/exits.ts
  • frontend/src/lib/quiz/machine.test.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/prefs.ts
  • frontend/src/lib/quiz/proposals.test.ts
  • frontend/src/lib/quiz/proposals.ts
  • frontend/src/lib/quiz/relativeTime.test.ts
  • frontend/src/lib/quiz/relativeTime.ts
  • frontend/src/lib/quiz/session.test.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/source.test.ts
  • frontend/src/lib/quiz/source.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useGamificationDelta.ts
  • frontend/src/lib/quiz/useQuizConfig.test.ts
  • frontend/src/lib/quiz/useQuizConfig.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts
  • frontend/src/lib/quiz/useQuizSession.ts
  • frontend/src/lib/quizSelection.ts
  • scripts/e2e-up.sh
  • supabase/snippets/.gitkeep

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

function isHrefAction(a: EmptyStateProps["action"]): a is { label: string; href: string } {
return (
typeof a === "object" &&
a !== null &&
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

Updates to Preview Branch (feat/537-quiz-frontend) ↗︎

DeploymentsStatusUpdated
DatabaseSun, 23 Aug 2026 06:10:30 UTC
ServicesSun, 23 Aug 2026 06:10:30 UTC
APIsSun, 23 Aug 2026 06:10:30 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

TasksStatusUpdated
ConfigurationsSun, 23 Aug 2026 06:10:38 UTC
MigrationsSun, 23 Aug 2026 06:10:42 UTC
SeedingSun, 23 Aug 2026 06:10:42 UTC
Edge FunctionsSun, 23 Aug 2026 06:10:42 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

@AndresL230
AndresL230 merged commit 7097c09 into mainAug 23, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) by AndresL230 · Pull Request #580 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) - #580

Merged
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend
Aug 23, 2026
Merged

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend)#580
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

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 QuizPanel implementation 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.md

What'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, /submit always at the end), the one error-code → copy module honouring Retry-After, a pure session state machine with persistence (no path out of active discards the attempt; source travels 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), Button link variant + forwardRef + a disabled-primary rule; graph/ConceptNode + ConceptNeighbourhood. The tree's node math is extracted once into lib/graph/nodeStyle.ts and 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 → buildQuizHref with from/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 /learn any more.

Removed: QuizPanel.tsx + test, screens/Quiz.tsx, the old api helpers, the old testids (registry rewritten in docs/frontend-testids.md).

Backend: untouched except QUIZ_GENERATE_RATE_LIMIT / _WINDOW_SEC becoming env-overridable (defaults unchanged, tested) — raised to 1000 in scripts/e2e-up.sh and e2e.yml only, because the in-process limiter is shared by a whole Playwright run.

Verification

Known seams (backend follow-ups, each a TODO(#537-followup) in code)

abandon/discard endpoint · feedback mode as a /config option · per-concept attempt filter · finished-attempt review payload · tutor session seed field · XP/streak in the submit response · question type on the wire · flag persistence · enriched /recommendations · a focus/camera prop on the graph renderers · /wiki still publishes pre-#557 tier ranges.

Note: quiz-product-flows.md / quiz-design-brief.md named 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

AndresL230and others added 30 commits August 22, 2026 15:35
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>
AndresL230and others added 24 commits August 22, 2026 17:18
…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>
…a link (#537 D1)
Follow-up to 3b81d61: B1's review round landed `aria-pressed` on the
proposal card's "adjust" control after the registry was written.
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>
)
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingd966da6Commit Preview URL

Branch Preview URL
Aug 23 2026, 06:11 AM

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Too 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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 597f6386-239b-4759-9595-7f62375d8d31

📥 Commits

Reviewing files that changed from the base of the PR and between 7863210 and d966da6.

📒 Files selected for processing (115)
  • .github/workflows/e2e.yml
  • backend/.env.example
  • backend/services/quiz_config.py
  • backend/tests/test_quiz_cost_observability_f.py
  • docs/e2e-exploration.md
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/graph.spec.ts
  • frontend/e2e/quiz-errors.spec.ts
  • frontend/e2e/quiz-integration.spec.ts
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/quiz.ts
  • frontend/e2e/support/quizStack.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/playwright.config.ts
  • frontend/src/app/(shell)/notetaker/page.generateQuiz.test.tsx
  • frontend/src/app/(shell)/notetaker/page.testmode.test.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/app/(shell)/quiz/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/Dialog.tsx
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.test.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.tsx
  • frontend/src/components/graph/ConceptNode.test.tsx
  • frontend/src/components/graph/ConceptNode.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/__fixtures__/knowledgeGraph2D.golden.json
  • frontend/src/components/quiz/QuizScreen.test.tsx
  • frontend/src/components/quiz/QuizScreen.tsx
  • frontend/src/components/quiz/home/AdjustDialog.tsx
  • frontend/src/components/quiz/home/ConceptDialog.tsx
  • frontend/src/components/quiz/home/PickList.tsx
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/components/quiz/home/QuizSettings.tsx
  • frontend/src/components/quiz/home/accent.ts
  • frontend/src/components/quiz/home/home.css
  • frontend/src/components/quiz/index.ts
  • frontend/src/components/quiz/question/AskPanel.test.tsx
  • frontend/src/components/quiz/question/AskPanel.tsx
  • frontend/src/components/quiz/question/LeaveDialog.tsx
  • frontend/src/components/quiz/question/QuizQuestion.test.tsx
  • frontend/src/components/quiz/question/QuizQuestion.tsx
  • frontend/src/components/quiz/question/question.css
  • frontend/src/components/quiz/quiz.css
  • frontend/src/components/quiz/results/MissedList.tsx
  • frontend/src/components/quiz/results/QuizResults.test.tsx
  • frontend/src/components/quiz/results/QuizResults.tsx
  • frontend/src/components/quiz/results/results.css
  • frontend/src/components/screens/Dashboard.quiz.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Tree.quiz.test.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/components/ui/AnswerOption.test.tsx
  • frontend/src/components/ui/AnswerOption.tsx
  • frontend/src/components/ui/Button.test.tsx
  • frontend/src/components/ui/Button.tsx
  • frontend/src/components/ui/EmptyState.test.tsx
  • frontend/src/components/ui/EmptyState.tsx
  • frontend/src/components/ui/InlineBanner.test.tsx
  • frontend/src/components/ui/InlineBanner.tsx
  • frontend/src/components/ui/ProgressDots.test.tsx
  • frontend/src/components/ui/ProgressDots.tsx
  • frontend/src/components/ui/SegmentedControl.test.tsx
  • frontend/src/components/ui/SegmentedControl.tsx
  • frontend/src/components/ui/Sheet.test.tsx
  • frontend/src/components/ui/Sheet.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.test.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx
  • frontend/src/components/ui/__fixtures__/quizPrimitivesGallery.css
  • frontend/src/components/ui/index.ts
  • frontend/src/lib/api.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/errorMessage.test.ts
  • frontend/src/lib/errorMessage.ts
  • frontend/src/lib/graph/neighbourhood.test.ts
  • frontend/src/lib/graph/neighbourhood.ts
  • frontend/src/lib/graph/nodeStyle.test.ts
  • frontend/src/lib/graph/nodeStyle.ts
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/exits.test.ts
  • frontend/src/lib/quiz/exits.ts
  • frontend/src/lib/quiz/machine.test.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/prefs.ts
  • frontend/src/lib/quiz/proposals.test.ts
  • frontend/src/lib/quiz/proposals.ts
  • frontend/src/lib/quiz/relativeTime.test.ts
  • frontend/src/lib/quiz/relativeTime.ts
  • frontend/src/lib/quiz/session.test.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/source.test.ts
  • frontend/src/lib/quiz/source.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useGamificationDelta.ts
  • frontend/src/lib/quiz/useQuizConfig.test.ts
  • frontend/src/lib/quiz/useQuizConfig.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts
  • frontend/src/lib/quiz/useQuizSession.ts
  • frontend/src/lib/quizSelection.ts
  • scripts/e2e-up.sh
  • supabase/snippets/.gitkeep

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

function isHrefAction(a: EmptyStateProps["action"]): a is { label: string; href: string } {
return (
typeof a === "object" &&
a !== null &&
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

Updates to Preview Branch (feat/537-quiz-frontend) ↗︎

DeploymentsStatusUpdated
DatabaseSun, 23 Aug 2026 06:10:30 UTC
ServicesSun, 23 Aug 2026 06:10:30 UTC
APIsSun, 23 Aug 2026 06:10:30 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

TasksStatusUpdated
ConfigurationsSun, 23 Aug 2026 06:10:38 UTC
MigrationsSun, 23 Aug 2026 06:10:42 UTC
SeedingSun, 23 Aug 2026 06:10:42 UTC
Edge FunctionsSun, 23 Aug 2026 06:10:42 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

@AndresL230
AndresL230 merged commit 7097c09 into mainAug 23, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) by AndresL230 · Pull Request #580 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) - #580

Merged
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend
Aug 23, 2026
Merged

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend)#580
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

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 QuizPanel implementation 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.md

What'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, /submit always at the end), the one error-code → copy module honouring Retry-After, a pure session state machine with persistence (no path out of active discards the attempt; source travels 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), Button link variant + forwardRef + a disabled-primary rule; graph/ConceptNode + ConceptNeighbourhood. The tree's node math is extracted once into lib/graph/nodeStyle.ts and 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 → buildQuizHref with from/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 /learn any more.

Removed: QuizPanel.tsx + test, screens/Quiz.tsx, the old api helpers, the old testids (registry rewritten in docs/frontend-testids.md).

Backend: untouched except QUIZ_GENERATE_RATE_LIMIT / _WINDOW_SEC becoming env-overridable (defaults unchanged, tested) — raised to 1000 in scripts/e2e-up.sh and e2e.yml only, because the in-process limiter is shared by a whole Playwright run.

Verification

Known seams (backend follow-ups, each a TODO(#537-followup) in code)

abandon/discard endpoint · feedback mode as a /config option · per-concept attempt filter · finished-attempt review payload · tutor session seed field · XP/streak in the submit response · question type on the wire · flag persistence · enriched /recommendations · a focus/camera prop on the graph renderers · /wiki still publishes pre-#557 tier ranges.

Note: quiz-product-flows.md / quiz-design-brief.md named 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

AndresL230and others added 30 commits August 22, 2026 15:35
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>
AndresL230and others added 24 commits August 22, 2026 17:18
…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>
…a link (#537 D1)
Follow-up to 3b81d61: B1's review round landed `aria-pressed` on the
proposal card's "adjust" control after the registry was written.
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>
)
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingd966da6Commit Preview URL

Branch Preview URL
Aug 23 2026, 06:11 AM

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Too 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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 597f6386-239b-4759-9595-7f62375d8d31

📥 Commits

Reviewing files that changed from the base of the PR and between 7863210 and d966da6.

📒 Files selected for processing (115)
  • .github/workflows/e2e.yml
  • backend/.env.example
  • backend/services/quiz_config.py
  • backend/tests/test_quiz_cost_observability_f.py
  • docs/e2e-exploration.md
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/graph.spec.ts
  • frontend/e2e/quiz-errors.spec.ts
  • frontend/e2e/quiz-integration.spec.ts
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/quiz.ts
  • frontend/e2e/support/quizStack.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/playwright.config.ts
  • frontend/src/app/(shell)/notetaker/page.generateQuiz.test.tsx
  • frontend/src/app/(shell)/notetaker/page.testmode.test.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/app/(shell)/quiz/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/Dialog.tsx
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.test.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.tsx
  • frontend/src/components/graph/ConceptNode.test.tsx
  • frontend/src/components/graph/ConceptNode.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/__fixtures__/knowledgeGraph2D.golden.json
  • frontend/src/components/quiz/QuizScreen.test.tsx
  • frontend/src/components/quiz/QuizScreen.tsx
  • frontend/src/components/quiz/home/AdjustDialog.tsx
  • frontend/src/components/quiz/home/ConceptDialog.tsx
  • frontend/src/components/quiz/home/PickList.tsx
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/components/quiz/home/QuizSettings.tsx
  • frontend/src/components/quiz/home/accent.ts
  • frontend/src/components/quiz/home/home.css
  • frontend/src/components/quiz/index.ts
  • frontend/src/components/quiz/question/AskPanel.test.tsx
  • frontend/src/components/quiz/question/AskPanel.tsx
  • frontend/src/components/quiz/question/LeaveDialog.tsx
  • frontend/src/components/quiz/question/QuizQuestion.test.tsx
  • frontend/src/components/quiz/question/QuizQuestion.tsx
  • frontend/src/components/quiz/question/question.css
  • frontend/src/components/quiz/quiz.css
  • frontend/src/components/quiz/results/MissedList.tsx
  • frontend/src/components/quiz/results/QuizResults.test.tsx
  • frontend/src/components/quiz/results/QuizResults.tsx
  • frontend/src/components/quiz/results/results.css
  • frontend/src/components/screens/Dashboard.quiz.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Tree.quiz.test.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/components/ui/AnswerOption.test.tsx
  • frontend/src/components/ui/AnswerOption.tsx
  • frontend/src/components/ui/Button.test.tsx
  • frontend/src/components/ui/Button.tsx
  • frontend/src/components/ui/EmptyState.test.tsx
  • frontend/src/components/ui/EmptyState.tsx
  • frontend/src/components/ui/InlineBanner.test.tsx
  • frontend/src/components/ui/InlineBanner.tsx
  • frontend/src/components/ui/ProgressDots.test.tsx
  • frontend/src/components/ui/ProgressDots.tsx
  • frontend/src/components/ui/SegmentedControl.test.tsx
  • frontend/src/components/ui/SegmentedControl.tsx
  • frontend/src/components/ui/Sheet.test.tsx
  • frontend/src/components/ui/Sheet.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.test.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx
  • frontend/src/components/ui/__fixtures__/quizPrimitivesGallery.css
  • frontend/src/components/ui/index.ts
  • frontend/src/lib/api.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/errorMessage.test.ts
  • frontend/src/lib/errorMessage.ts
  • frontend/src/lib/graph/neighbourhood.test.ts
  • frontend/src/lib/graph/neighbourhood.ts
  • frontend/src/lib/graph/nodeStyle.test.ts
  • frontend/src/lib/graph/nodeStyle.ts
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/exits.test.ts
  • frontend/src/lib/quiz/exits.ts
  • frontend/src/lib/quiz/machine.test.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/prefs.ts
  • frontend/src/lib/quiz/proposals.test.ts
  • frontend/src/lib/quiz/proposals.ts
  • frontend/src/lib/quiz/relativeTime.test.ts
  • frontend/src/lib/quiz/relativeTime.ts
  • frontend/src/lib/quiz/session.test.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/source.test.ts
  • frontend/src/lib/quiz/source.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useGamificationDelta.ts
  • frontend/src/lib/quiz/useQuizConfig.test.ts
  • frontend/src/lib/quiz/useQuizConfig.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts
  • frontend/src/lib/quiz/useQuizSession.ts
  • frontend/src/lib/quizSelection.ts
  • scripts/e2e-up.sh
  • supabase/snippets/.gitkeep

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

function isHrefAction(a: EmptyStateProps["action"]): a is { label: string; href: string } {
return (
typeof a === "object" &&
a !== null &&
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

Updates to Preview Branch (feat/537-quiz-frontend) ↗︎

DeploymentsStatusUpdated
DatabaseSun, 23 Aug 2026 06:10:30 UTC
ServicesSun, 23 Aug 2026 06:10:30 UTC
APIsSun, 23 Aug 2026 06:10:30 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

TasksStatusUpdated
ConfigurationsSun, 23 Aug 2026 06:10:38 UTC
MigrationsSun, 23 Aug 2026 06:10:42 UTC
SeedingSun, 23 Aug 2026 06:10:42 UTC
Edge FunctionsSun, 23 Aug 2026 06:10:42 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

@AndresL230
AndresL230 merged commit 7097c09 into mainAug 23, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) by AndresL230 · Pull Request #580 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) - #580

Merged
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend
Aug 23, 2026
Merged

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend)#580
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

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 QuizPanel implementation 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.md

What'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, /submit always at the end), the one error-code → copy module honouring Retry-After, a pure session state machine with persistence (no path out of active discards the attempt; source travels 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), Button link variant + forwardRef + a disabled-primary rule; graph/ConceptNode + ConceptNeighbourhood. The tree's node math is extracted once into lib/graph/nodeStyle.ts and 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 → buildQuizHref with from/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 /learn any more.

Removed: QuizPanel.tsx + test, screens/Quiz.tsx, the old api helpers, the old testids (registry rewritten in docs/frontend-testids.md).

Backend: untouched except QUIZ_GENERATE_RATE_LIMIT / _WINDOW_SEC becoming env-overridable (defaults unchanged, tested) — raised to 1000 in scripts/e2e-up.sh and e2e.yml only, because the in-process limiter is shared by a whole Playwright run.

Verification

Known seams (backend follow-ups, each a TODO(#537-followup) in code)

abandon/discard endpoint · feedback mode as a /config option · per-concept attempt filter · finished-attempt review payload · tutor session seed field · XP/streak in the submit response · question type on the wire · flag persistence · enriched /recommendations · a focus/camera prop on the graph renderers · /wiki still publishes pre-#557 tier ranges.

Note: quiz-product-flows.md / quiz-design-brief.md named 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

AndresL230and others added 30 commits August 22, 2026 15:35
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>
AndresL230and others added 24 commits August 22, 2026 17:18
…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>
…a link (#537 D1)
Follow-up to 3b81d61: B1's review round landed `aria-pressed` on the
proposal card's "adjust" control after the registry was written.
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>
)
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingd966da6Commit Preview URL

Branch Preview URL
Aug 23 2026, 06:11 AM

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Too 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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 597f6386-239b-4759-9595-7f62375d8d31

📥 Commits

Reviewing files that changed from the base of the PR and between 7863210 and d966da6.

📒 Files selected for processing (115)
  • .github/workflows/e2e.yml
  • backend/.env.example
  • backend/services/quiz_config.py
  • backend/tests/test_quiz_cost_observability_f.py
  • docs/e2e-exploration.md
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/graph.spec.ts
  • frontend/e2e/quiz-errors.spec.ts
  • frontend/e2e/quiz-integration.spec.ts
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/quiz.ts
  • frontend/e2e/support/quizStack.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/playwright.config.ts
  • frontend/src/app/(shell)/notetaker/page.generateQuiz.test.tsx
  • frontend/src/app/(shell)/notetaker/page.testmode.test.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/app/(shell)/quiz/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/Dialog.tsx
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.test.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.tsx
  • frontend/src/components/graph/ConceptNode.test.tsx
  • frontend/src/components/graph/ConceptNode.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/__fixtures__/knowledgeGraph2D.golden.json
  • frontend/src/components/quiz/QuizScreen.test.tsx
  • frontend/src/components/quiz/QuizScreen.tsx
  • frontend/src/components/quiz/home/AdjustDialog.tsx
  • frontend/src/components/quiz/home/ConceptDialog.tsx
  • frontend/src/components/quiz/home/PickList.tsx
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/components/quiz/home/QuizSettings.tsx
  • frontend/src/components/quiz/home/accent.ts
  • frontend/src/components/quiz/home/home.css
  • frontend/src/components/quiz/index.ts
  • frontend/src/components/quiz/question/AskPanel.test.tsx
  • frontend/src/components/quiz/question/AskPanel.tsx
  • frontend/src/components/quiz/question/LeaveDialog.tsx
  • frontend/src/components/quiz/question/QuizQuestion.test.tsx
  • frontend/src/components/quiz/question/QuizQuestion.tsx
  • frontend/src/components/quiz/question/question.css
  • frontend/src/components/quiz/quiz.css
  • frontend/src/components/quiz/results/MissedList.tsx
  • frontend/src/components/quiz/results/QuizResults.test.tsx
  • frontend/src/components/quiz/results/QuizResults.tsx
  • frontend/src/components/quiz/results/results.css
  • frontend/src/components/screens/Dashboard.quiz.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Tree.quiz.test.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/components/ui/AnswerOption.test.tsx
  • frontend/src/components/ui/AnswerOption.tsx
  • frontend/src/components/ui/Button.test.tsx
  • frontend/src/components/ui/Button.tsx
  • frontend/src/components/ui/EmptyState.test.tsx
  • frontend/src/components/ui/EmptyState.tsx
  • frontend/src/components/ui/InlineBanner.test.tsx
  • frontend/src/components/ui/InlineBanner.tsx
  • frontend/src/components/ui/ProgressDots.test.tsx
  • frontend/src/components/ui/ProgressDots.tsx
  • frontend/src/components/ui/SegmentedControl.test.tsx
  • frontend/src/components/ui/SegmentedControl.tsx
  • frontend/src/components/ui/Sheet.test.tsx
  • frontend/src/components/ui/Sheet.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.test.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx
  • frontend/src/components/ui/__fixtures__/quizPrimitivesGallery.css
  • frontend/src/components/ui/index.ts
  • frontend/src/lib/api.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/errorMessage.test.ts
  • frontend/src/lib/errorMessage.ts
  • frontend/src/lib/graph/neighbourhood.test.ts
  • frontend/src/lib/graph/neighbourhood.ts
  • frontend/src/lib/graph/nodeStyle.test.ts
  • frontend/src/lib/graph/nodeStyle.ts
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/exits.test.ts
  • frontend/src/lib/quiz/exits.ts
  • frontend/src/lib/quiz/machine.test.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/prefs.ts
  • frontend/src/lib/quiz/proposals.test.ts
  • frontend/src/lib/quiz/proposals.ts
  • frontend/src/lib/quiz/relativeTime.test.ts
  • frontend/src/lib/quiz/relativeTime.ts
  • frontend/src/lib/quiz/session.test.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/source.test.ts
  • frontend/src/lib/quiz/source.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useGamificationDelta.ts
  • frontend/src/lib/quiz/useQuizConfig.test.ts
  • frontend/src/lib/quiz/useQuizConfig.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts
  • frontend/src/lib/quiz/useQuizSession.ts
  • frontend/src/lib/quizSelection.ts
  • scripts/e2e-up.sh
  • supabase/snippets/.gitkeep

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

function isHrefAction(a: EmptyStateProps["action"]): a is { label: string; href: string } {
return (
typeof a === "object" &&
a !== null &&
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

Updates to Preview Branch (feat/537-quiz-frontend) ↗︎

DeploymentsStatusUpdated
DatabaseSun, 23 Aug 2026 06:10:30 UTC
ServicesSun, 23 Aug 2026 06:10:30 UTC
APIsSun, 23 Aug 2026 06:10:30 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

TasksStatusUpdated
ConfigurationsSun, 23 Aug 2026 06:10:38 UTC
MigrationsSun, 23 Aug 2026 06:10:42 UTC
SeedingSun, 23 Aug 2026 06:10:42 UTC
Edge FunctionsSun, 23 Aug 2026 06:10:42 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

@AndresL230
AndresL230 merged commit 7097c09 into mainAug 23, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) by AndresL230 · Pull Request #580 · SaplingLearn/Sapling · GitHub
Skip to content

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) - #580

Merged
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend
Aug 23, 2026
Merged

feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend)#580
AndresL230 merged 60 commits into
mainfrom
feat/537-quiz-frontend

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

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 QuizPanel implementation 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.md

What'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, /submit always at the end), the one error-code → copy module honouring Retry-After, a pure session state machine with persistence (no path out of active discards the attempt; source travels 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), Button link variant + forwardRef + a disabled-primary rule; graph/ConceptNode + ConceptNeighbourhood. The tree's node math is extracted once into lib/graph/nodeStyle.ts and 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 → buildQuizHref with from/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 /learn any more.

Removed: QuizPanel.tsx + test, screens/Quiz.tsx, the old api helpers, the old testids (registry rewritten in docs/frontend-testids.md).

Backend: untouched except QUIZ_GENERATE_RATE_LIMIT / _WINDOW_SEC becoming env-overridable (defaults unchanged, tested) — raised to 1000 in scripts/e2e-up.sh and e2e.yml only, because the in-process limiter is shared by a whole Playwright run.

Verification

Known seams (backend follow-ups, each a TODO(#537-followup) in code)

abandon/discard endpoint · feedback mode as a /config option · per-concept attempt filter · finished-attempt review payload · tutor session seed field · XP/streak in the submit response · question type on the wire · flag persistence · enriched /recommendations · a focus/camera prop on the graph renderers · /wiki still publishes pre-#557 tier ranges.

Note: quiz-product-flows.md / quiz-design-brief.md named 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

AndresL230and others added 30 commits August 22, 2026 15:35
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>
AndresL230and others added 24 commits August 22, 2026 17:18
…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>
…a link (#537 D1)
Follow-up to 3b81d61: B1's review round landed `aria-pressed` on the
proposal card's "adjust" control after the registry was written.
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>
)
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingd966da6Commit Preview URL

Branch Preview URL
Aug 23 2026, 06:11 AM

@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Too 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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 597f6386-239b-4759-9595-7f62375d8d31

📥 Commits

Reviewing files that changed from the base of the PR and between 7863210 and d966da6.

📒 Files selected for processing (115)
  • .github/workflows/e2e.yml
  • backend/.env.example
  • backend/services/quiz_config.py
  • backend/tests/test_quiz_cost_observability_f.py
  • docs/e2e-exploration.md
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/graph.spec.ts
  • frontend/e2e/quiz-errors.spec.ts
  • frontend/e2e/quiz-integration.spec.ts
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/e2e/quiz.spec.ts
  • frontend/e2e/support/quiz.ts
  • frontend/e2e/support/quizStack.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/playwright.config.ts
  • frontend/src/app/(shell)/notetaker/page.generateQuiz.test.tsx
  • frontend/src/app/(shell)/notetaker/page.testmode.test.tsx
  • frontend/src/app/(shell)/notetaker/page.tsx
  • frontend/src/app/(shell)/quiz/page.tsx
  • frontend/src/app/globals.css
  • frontend/src/components/Dialog.tsx
  • frontend/src/components/QuizPanel.test.tsx
  • frontend/src/components/QuizPanel.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.test.tsx
  • frontend/src/components/graph/ConceptNeighbourhood.tsx
  • frontend/src/components/graph/ConceptNode.test.tsx
  • frontend/src/components/graph/ConceptNode.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.testmode.test.tsx
  • frontend/src/components/graph/KnowledgeGraph2D.tsx
  • frontend/src/components/graph/KnowledgeGraph3D.tsx
  • frontend/src/components/graph/__fixtures__/knowledgeGraph2D.golden.json
  • frontend/src/components/quiz/QuizScreen.test.tsx
  • frontend/src/components/quiz/QuizScreen.tsx
  • frontend/src/components/quiz/home/AdjustDialog.tsx
  • frontend/src/components/quiz/home/ConceptDialog.tsx
  • frontend/src/components/quiz/home/PickList.tsx
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/components/quiz/home/QuizSettings.tsx
  • frontend/src/components/quiz/home/accent.ts
  • frontend/src/components/quiz/home/home.css
  • frontend/src/components/quiz/index.ts
  • frontend/src/components/quiz/question/AskPanel.test.tsx
  • frontend/src/components/quiz/question/AskPanel.tsx
  • frontend/src/components/quiz/question/LeaveDialog.tsx
  • frontend/src/components/quiz/question/QuizQuestion.test.tsx
  • frontend/src/components/quiz/question/QuizQuestion.tsx
  • frontend/src/components/quiz/question/question.css
  • frontend/src/components/quiz/quiz.css
  • frontend/src/components/quiz/results/MissedList.tsx
  • frontend/src/components/quiz/results/QuizResults.test.tsx
  • frontend/src/components/quiz/results/QuizResults.tsx
  • frontend/src/components/quiz/results/results.css
  • frontend/src/components/screens/Dashboard.quiz.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Gradebook/Landing.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Tree.quiz.test.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/components/ui/AnswerOption.test.tsx
  • frontend/src/components/ui/AnswerOption.tsx
  • frontend/src/components/ui/Button.test.tsx
  • frontend/src/components/ui/Button.tsx
  • frontend/src/components/ui/EmptyState.test.tsx
  • frontend/src/components/ui/EmptyState.tsx
  • frontend/src/components/ui/InlineBanner.test.tsx
  • frontend/src/components/ui/InlineBanner.tsx
  • frontend/src/components/ui/ProgressDots.test.tsx
  • frontend/src/components/ui/ProgressDots.tsx
  • frontend/src/components/ui/SegmentedControl.test.tsx
  • frontend/src/components/ui/SegmentedControl.tsx
  • frontend/src/components/ui/Sheet.test.tsx
  • frontend/src/components/ui/Sheet.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.test.tsx
  • frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx
  • frontend/src/components/ui/__fixtures__/quizPrimitivesGallery.css
  • frontend/src/components/ui/index.ts
  • frontend/src/lib/api.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/errorMessage.test.ts
  • frontend/src/lib/errorMessage.ts
  • frontend/src/lib/graph/neighbourhood.test.ts
  • frontend/src/lib/graph/neighbourhood.ts
  • frontend/src/lib/graph/nodeStyle.test.ts
  • frontend/src/lib/graph/nodeStyle.ts
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/exits.test.ts
  • frontend/src/lib/quiz/exits.ts
  • frontend/src/lib/quiz/machine.test.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/prefs.ts
  • frontend/src/lib/quiz/proposals.test.ts
  • frontend/src/lib/quiz/proposals.ts
  • frontend/src/lib/quiz/relativeTime.test.ts
  • frontend/src/lib/quiz/relativeTime.ts
  • frontend/src/lib/quiz/session.test.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/source.test.ts
  • frontend/src/lib/quiz/source.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useGamificationDelta.ts
  • frontend/src/lib/quiz/useQuizConfig.test.ts
  • frontend/src/lib/quiz/useQuizConfig.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts
  • frontend/src/lib/quiz/useQuizSession.ts
  • frontend/src/lib/quizSelection.ts
  • scripts/e2e-up.sh
  • supabase/snippets/.gitkeep

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

function isHrefAction(a: EmptyStateProps["action"]): a is { label: string; href: string } {
return (
typeof a === "object" &&
a !== null &&
@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

Updates to Preview Branch (feat/537-quiz-frontend) ↗︎

DeploymentsStatusUpdated
DatabaseSun, 23 Aug 2026 06:10:30 UTC
ServicesSun, 23 Aug 2026 06:10:30 UTC
APIsSun, 23 Aug 2026 06:10:30 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

TasksStatusUpdated
ConfigurationsSun, 23 Aug 2026 06:10:38 UTC
MigrationsSun, 23 Aug 2026 06:10:42 UTC
SeedingSun, 23 Aug 2026 06:10:42 UTC
Edge FunctionsSun, 23 Aug 2026 06:10:42 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

@AndresL230
AndresL230 merged commit 7097c09 into mainAug 23, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230