Skip to content

feat(quiz): comprehension quiz for the current diff (#191) - #192

Merged
adnaan merged 18 commits into
mainfrom
issue-191-quiz
Jul 22, 2026
Merged

feat(quiz): comprehension quiz for the current diff (#191)#192
adnaan merged 18 commits into
mainfrom
issue-191-quiz

Conversation

@adnaan

@adnaan adnaan commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Implements #191 — a button that asks the agent to quiz you on the diff you're about to accept, rendered in a new quiz view.

Why

An accept verdict in prereview records that the reviewer clicked, not that they understood.

CodeReviewQA (Findings of ACL 2025) is the motivating result: models that produced a correct refinement still failed the intermediate comprehension probes for 49.0–99.6% of those same successes. Producing the right output is not evidence of understanding it — exactly the gap between "the agent's diff passes" and "the human reviewing it understood it." Three of its reasoning probes became three of the five probe kinds here.

Answering is retrieval practice, which is also where the "every question ships an explanation, revealed after you answer" requirement comes from.

What makes it not slop

A generic "make me a quiz" button produces generic questions. The difference: prereview owns the diff, so it can verify every cited line range exists. Three states, and the last two must not collapse:

State Meaning Renders
ok the cited line is in the diff a jump link to that code
ungrounded cited a line not in the diff — a hallucinated anchor a warning, and no jump link
absent a decision about an omission, which has nothing to point at a neutral marker

Collapsing ungrounded into absent would let a hallucinated anchor hide behind a legitimate label. Distinct in Go, template, CSS, a render golden, and a browser test.

The decision probe

Four probes ask whether you understood what is in the diff. The fifth asks what the agent decided that you never asked for — an unrequested dependency, a changed default, a widened interface, a skipped edge case. It's diff-versus-intent rather than diff-internal, so only the agent that did the work can author it; a reviewer skimming can't see that gap precisely because the code looks fine.

Its honest limitation is recorded in the plan and the prompt: it's self-reported by the party being reported on, so under-reporting is the expected failure. It raises the floor; it is not a safety net.

Architecture — existing patterns, cloned

  • Request rides the Prompts path (Ask for suggestions #147): "Quiz me" saves a kind=file comment carrying the prompt. No new comment kind, no second channel. Visible in the reviewer's queue — Comment.Hidden only applies to resolved comments, so hiding it isn't possible and isn't worth inventing.
  • Return clones suggest/decision: quiz.jsonl agent-owned and append-only, quiz-answers.jsonl server-owned and atomically rewritten. Two files, one writer each — the thread.go rule.
  • Quiz prompts are a user-extensible library (~/.config/prereview/quizzes/*.md, PREREVIEW_QUIZZES_DIR), reusing LoadPrompts' loader. Safe only because the prompt is guidance and never the guarantee: ValidateQuiz (structure) and applyQuiz (grounding) bind whatever prompt produced the quiz.
  • Advisory record: the snapshot gains quizzes (file, score, total, taken). Never gates. taken is separate from the score because "never opened it" and "answered everything wrong" both serialize as 0/N and mean opposite things. Nil ⇒ omitempty ⇒ wire format unchanged for reviews that don't use the feature.

Verification

Full unit suite, both golden guards, and all 152 browser e2e green (440s, 0 skips).

Four defects the layer below couldn't see:

  • --file - didn't read stdin, though done/reply/applied all document it — only the e2e drove the verb the way an agent does.
  • A stray list marker painted left of every option; DOM assertions pass either way, only a screenshot shows it.
  • Dark mode was unguarded (the contrast walker never opens this view), so it was screenshot-checked.
  • "Quiz me" initially reused .prompts-trigger, which silently re-pointed TestE2E_PromptPicker's click at the wrong button — it didn't fail, it hung, taking the suite past its timeout. Fixed with its own class + grouped styles, plus a guard asserting that selector matches exactly one element so a recurrence fails in seconds.

Deferred

Staleness detection. It needed a fingerprint of the quizzed content, but the server can't compute one retroactively and the agent can't compute prereview's hash — it would mean asking the agent to shell out a sha256 and trusting it hashed the same bytes. Grounding already covers the visible symptom: edit the file and the cited lines stop resolving. Reasoning recorded rather than the checkbox silently dropped.

Also out of scope, per the plan: folder-wide quizzes and free-recall questions (M2).

🤖 Generated with Claude Code

https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp

adnaan and others added 3 commits July 20, 2026 00:49
… phase 1)

Phase 1 of the comprehension-quiz feature: the agent-facing half, headless.
A reviewer will ask for a quiz about the current diff; the agent authors it
and submits it here. No UI yet — Phase 2 wires the server and the view.

The shape clones two existing patterns rather than inventing machinery:
quiz.jsonl is agent-owned and append-only like suggestions.jsonl (so it never
races the server's comments.csv), quiz-answers.jsonl is server-owned, and the
two are merged at load — the one-writer-per-file rule from thread.go.

The load-bearing decision is WHERE the quality contract lives. Quiz prompts
are a user-extensible library (built-ins plus ~/.config/prereview/quizzes),
so no guarantee may depend on the prompt's text. Structure is enforced by
ValidateQuiz in the review package — known probe, >=2 options, answer index in
range, non-empty explanation — which binds whatever prompt produced the quiz;
grounding (does the cited line range exist?) is left to the server, the side
that holds the diff. A sloppy custom prompt can make a quiz boring, but not
structurally invalid or hallucinated.

Two subtleties worth flagging for later phases:

- `decision` questions may be about an ABSENCE ("chose not to add a test"),
  which has no lines to point at, so from_line 0 is legal for that probe only.
  That state must stay distinct from "claimed an anchor that doesn't resolve":
  one is deliberate, the other is a hallucination, and collapsing them would
  let hallucinated anchors hide behind a legitimate label.
- Side is defaulted on BOTH the write path (NormalizeQuiz) and the read path
  (loadQuizzes), because the file is append-only and a line can arrive without
  going through the CLI. A question with no side jumps to the wrong row of a
  modified line.

Audit findings that changed the plan: Comment.Hidden turned out to be
"dismiss a RESOLVED comment", meaningless on unresolved ones, so a quiz
request cannot be silently hidden — Phase 4 will clone the Save path and the
request comment will be visible, matching how a saved Prompt comment already
behaves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
Wires the server half and the UI. An agent-written quiz.jsonl now appears
live in a new full-screen view (peer of the all-comments pane), and the
reviewer can answer it, see why each answer is right, and jump to the code a
question cites.

The load-bearing addition is GROUNDING — the anti-slop lever the CLI verb
could not enforce because only the server holds the diff. Each question
claims a line range; applyQuiz checks that line actually exists in the file
under review and stamps one of three states:

  ok          → renders a jump link to the cited code
  ungrounded  → the question cited a line that is NOT in this diff, i.e. a
                hallucinated anchor. Rendered as a warning, and deliberately
                offered NO jump link: the link would land on unrelated code.
  absent      → a `decision` question about an OMISSION ("chose not to add a
                test"), which has nothing to point at. Legitimate, not a
                warning.

The last two must never collapse into one state. Both render without a jump,
but one is deliberate and the other is a defect; giving them the same label
would let a hallucinated anchor hide behind a legitimate one. They are
distinct in Go, in the template, in CSS, and are pinned by both a render
golden and a browser test.

Answers are the reviewer's, so they go in a server-owned quiz-answers.jsonl
rewritten atomically — never into the agent's append-only quiz.jsonl. One
writer per file, per the thread.go rule.

Scope note: phase 3's core (answering, scoring, explanation reveal, retake,
jump-to-cited-line) landed here rather than separately, because a quiz you
cannot answer is not reviewable. Phase 3 retains staleness detection.

Verified beyond the unit suite, since three defects here were invisible to it:

- `prereview quiz --file -` did not read stdin, though done/reply/applied all
  document "-" for it. Only the e2e drove the verb the way an agent does.
- A stray list marker painted left of every option (Pico re-asserts a marker
  on the nested <ul>). The DOM tests pass either way; only a screenshot shows
  it. Fixed by resetting list-style on the items, not just the lists.
- Dark mode was unguarded — the contrast walker never opens this view — so it
  was checked by screenshot too. No Pico fall-through; all tokens flip.

All 151 browser tests pass. Render goldens gained quiz + quiz-empty fixtures;
the only pre-existing golden movement is the new `z` keybinding, traced in
both the markup and the wire-format payloads before regenerating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
#191 phases 4-5)

Closes the loop. Until now a quiz could only arrive by hand-writing JSON; now
the reviewer taps "Quiz me" in the file header and the agent answers.

REQUEST. RequestQuiz saves a kind=file comment whose body is the quiz prompt,
riding the ordinary comment queue — no new comment kind, no second channel,
exactly how a saved Prompt comment (#147) already reaches the agent. It sends
on one tap rather than pre-filling the composer, because this tool is mostly
driven from a phone and there is rarely anything to tweak. The request is
therefore VISIBLE in the reviewer's own queue: an earlier design hoped to hide
it behind Comment.Hidden, but that flag only applies to RESOLVED comments, so
there is no way to hide it and no reason to invent one. The visible row
doubles as a record of what was asked, with the agent's reply threaded under it.

The control degrades by library size: one prompt renders a plain button, two
or more render a picker. Both shapes are pinned by render goldens.

ADVISORY RECORD. The snapshot gains a `quizzes` array — file, score, total,
and `taken`. It never gates anything; it lets the agent tell "accepted after a
comprehension check" from "accepted without one", which is the entire point,
since an accept otherwise records only that the reviewer clicked. `taken` is a
separate field rather than inferred from a non-zero score because "never
opened it" and "answered everything wrong" both serialize as 0/N and mean
opposite things. Nil when no quiz exists, so `omitempty` leaves the wire
format byte-identical for every review that does not use the feature.

DEFERRED, deliberately: staleness detection. It needed a fingerprint of the
quizzed content, but the server cannot compute one retroactively (it cannot
distinguish a first load from a reload-after-edit) and the agent cannot
compute prereview's hash — making it work means asking the agent to shell out
a sha256 and trusting it hashed the same bytes. Grounding already covers the
visible symptom: edit the file and the cited lines stop resolving, so those
questions render ungrounded. Reasoning recorded in the plan.

One regression caught by the full sweep and worth naming, because the failure
mode is nasty: "Quiz me" initially reused the `.prompts-trigger` class for
visual consistency. That made the selector ambiguous and silently re-pointed
TestE2E_PromptPicker's click at the wrong button — which did not fail, it
HUNG, waiting for a dropdown that never opened, and took the whole suite past
its timeout. The quiz control now carries its own class with the styling
shared via grouped selectors, and a guard asserts `.prompts-trigger` matches
exactly one element so a recurrence fails in seconds instead of hanging.

All 152 browser tests pass (440s, 0 skips); full unit suite and both golden
guards green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
@adnaan adnaan changed the title feat(quiz): comprehension quiz for the current diff (#191, phases 1–2) feat(quiz): comprehension quiz for the current diff (#191) Jul 20, 2026
adnaan and others added 15 commits July 20, 2026 03:24
…191)

Reported from a real phone: tapping "Quiz me" appeared to do nothing, so the
reviewer tapped again and queued two identical requests.

They were right to. The tap saved a comment and changed NOTHING visible — on a
narrow viewport the queue sits behind a menu, so the only feedback was silence,
and silence reads as "it didn't work".

Two changes:

- The tap is now acknowledged, with a toast through the existing Flash
  machinery ("Quiz requested — the agent will answer shortly").
- While the request is unanswered the button is replaced by a non-tappable
  "Quiz requested…", so a second tap CANNOT queue a duplicate. It returns once
  the quiz arrives, so another can still be asked for.

QuizRequestedFile is a session-scoped UI hint, deliberately not persisted:
worst case a reconnect re-offers the button, which is merely the old behaviour.

Worth recording why the test suite missed this. All 152 browser tests passed
with the bug present, because every one asserted the STATE TRANSITION — a
comment was saved — and none asserted that a human could TELL it had happened.
"The action worked" and "the user can see that it worked" are different
properties, and only the first was covered anywhere.

The new test is written from the report and runs at phone width, asserting all
three parts: the tap is confirmed, no second request is possible while one is
pending, and the control recovers afterwards. It fails without the fix.

153 browser tests pass (0 skips); unit suite and both golden guards green.
Only the signature golden moved — QuizPending is false in every fixture, so
rendered markup is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
#191)

Found on a real phone. The quiz request carried the ENTIRE built-in prompt as
its comment body — ~40 lines — and a kind=file comment renders in full in the
file view. Opening the file showed a wall of instructions where the code should
be. On a narrow viewport it filled the screen.

This is the visible-request trade-off the previous commit accepted, and it was
worse in practice than judged: "the request is visible" is fine, "the request
is the entire page" is not.

The fix is that the long contract never needed to be in the queue. SKILL.md and
clients/body.md already carry the probe taxonomy, the schema and the anchoring
rules to every agent out of band, so the comment only needs the essentials:
do not edit, do not suggest, answer with `prereview quiz`, 3-5 questions
grounded in the diff, at least one `decision`. 40 lines to 10.

A second-order property makes the short body safe: if an agent still gets the
schema wrong, ValidateQuiz rejects the payload naming the offending question,
so the validator teaches the contract on failure and the queue doesn't have to.

Guarded by a test that caps the built-in body's length and asserts the three
instructions that must survive any future shortening (`prereview quiz`, the
NOT-`prereview suggest` rule, and `decision`) — without those a quiz request
reads like a suggestions prompt and the agent proposes edits instead.

Also worth recording, though it needed no code: the reviewer could not see the
quiz at all, and that was a DEMO defect, not a product one. The demo repo had
the 26 MB prereview binary built into the reviewed tree; it sorted ahead of the
real file, so the session opened on "file too large to review" and — since the
quiz entry is correctly scoped to the selected file — showed no quiz. Diagnosed
by pointing a phone-sized headless browser at the live server rather than a
synthetic fixture, which is the check that should have run after the first
report instead of a reassurance.

153 browser tests pass (0 skips); unit suite and both golden guards green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
Reported from real use on a phone: "it's difficult to recall from the code what
we are talking about — I need to switch to the code, read it once more, and then
come back to the quiz."

This overturns a call I made in the Phase 2 audit and wrote down as deliberate:
"the jump deliberately ejects the reviewer from the quiz — that may be right for
v1, go read the code and toggle back." It is not right, for a specific reason: a
comprehension quiz you cannot see the code for stops testing comprehension and
starts testing short-term memory. The round trip also costs you the question,
since by the time you are back you have to re-read that too — two context
switches per question, on a phone.

So bring the code to the question instead of sending the reader to the code.
Each question now renders its cited lines inline: real syntax highlighting
(reusing DiffLine.HighlightedContent), the cited lines solid, two lines of
context either side dimmed for orientation, capped at 14 lines so a large range
cannot push the options off screen, and horizontally scrollable rather than
wrapped — a wrapped line misrepresents the code being asked about.

The jump link stays: the excerpt handles recall, the jump handles depth.

Two behaviours fall out of the anchoring data the schema already required, and
both reinforce the existing markers rather than contradicting them:

  - an UNGROUNDED question gets no excerpt, because its cited range does not
    resolve — there is nothing to show, which is the point of the warning;
  - an anchorless `decision` question gets none either, because it is about
    something that is not in the diff at all.

Both are asserted in the browser test, alongside a check that cited lines stay
visually distinct from context lines — an excerpt that shows code without
showing which part is being asked about would defeat the purpose.

153 browser tests pass (0 skips); unit suite and both golden guards green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
… screen (#191)

Reviewer's proposal, and it is the right one: "wouldn't it be better to have
quiz questions behave like comments and suggestions as an annotation to a line
or range of lines? this way everything remains consistent."

It is, and the previous commit was evidence for it. That one added an inline
code EXCERPT to each question because the quiz replaced the diff and you could
not see what you were being asked about. But "show the code next to the
annotation" is something prereview already does natively by anchoring
annotations to lines — the excerpt was a worse, read-only re-implementation of
the diff view, nested inside a screen that should not have existed. It is
deleted here.

Questions now render exactly where comments and suggestions do:

  - line-anchored ones inline in the diff row they ask about (QuizByEndLine,
    the same shape as CommentsByEndLine / SuggestionsByEndLine);
  - kind=file ones at the file head, the slot a kind=file COMMENT occupies.

The schema gains the comment anchor vocabulary — line / text / file / area /
region. Only line and file render for now, but storing the full vocabulary
means adding image-region or live-page questions later is pure rendering work
rather than a migration of quizzes already on disk. The old "from_line": 0
sentinel for "about something absent" is gone: that is kind=file, stated in the
same words a whole-file comment uses instead of a parallel invention.

Two bugs surfaced by the rework, both worth recording:

1. UNGROUNDED QUESTIONS WOULD HAVE VANISHED. A question citing a line the diff
   does not contain has no row to render in, so it simply would not appear —
   HIDING a hallucinated question instead of surfacing it, which is precisely
   what the grounding check exists to prevent. They now render at the file head
   with their warning, alongside the kind=file ones.

2. A LATENT ORDERING BUG FROM PHASE 2. Mount loaded the quizzes ~100 lines
   before it loaded CurrentDiff, so grounding compared every cited line against
   a nil diff and condemned the entire quiz. It stayed invisible while the quiz
   was a separate screen, because ToggleQuiz re-grounded on open by which point
   the diff was in hand — the extra screen was accidentally masking a broken
   Mount path. Rendering inline removes that second chance and it failed
   immediately, with every question flagged "not in this diff". Grounding now
   runs beside the other re-anchor calls, after the diff is loaded.

The quiz screen survives as the OVERVIEW — score, retake, and the questions in
one list — exactly as inline comments coexist with the all-comments view.

The browser test asserts the inline surface first and answers a question in
place; its two placement assertions double as the regression guard for (2),
since a nil-diff grounding sends every card to the file head.

153 browser tests pass (0 skips); unit suite and both golden guards green. A new
quiz-inline render fixture pins the annotation rendering, which the overview
fixture could not reach.

Still to come: threads on a question, so the reviewer can ask the agent to
clarify or revise one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
…191)

Second half of the reviewer's proposal: "we should also be able to reply to a
quiz question to have a conversation with the LLM to clarify or update things".

A quiz question is a claim the agent made about your code, and sometimes the
claim is wrong — an ambiguous option, a mis-read of the diff, a question whose
"correct" answer isn't. Until now the only response was to get it wrong and
move on. Now each question carries a thread: the reviewer pushes back in place,
the agent answers or revises.

This needed almost no new machinery, which is itself an argument for modelling
questions as annotations. ThreadEntry.TargetID is a bare string and
PostReply/OpenReply never cared what KIND of thing an id names, so the whole
reviewer side worked unchanged the moment questions had ids to point at.

The one genuinely new piece is IDENTITY. A question id ("q1") is unique only
within its quiz, while a thread target is global — so the target is the
composite "<quizID>:<questionID>". Colon-separated rather than NUL-joined like
the internal answer key, because unlike that key this one is typed by a human or
an agent on a command line. `prereview reply` validates it against the loaded
quizzes and fails loudly on an unknown question, matching how it already treats
comment and suggestion ids.

The skill now also says what to do when the reviewer is RIGHT: re-submit the
quiz with the same id (last write wins) with that question corrected, then reply
saying what changed. "This question is wrong" should be actionable, not merely
acknowledged.

The browser test drives the full round trip — reviewer replies in place, agent
answers out of process via the CLI, the answer appears live — plus two
properties worth pinning: a reply attaches to its OWN question rather than
leaking onto the next card, and an unknown question id is rejected instead of
recording a dangling thread.

One note on that test: it first waited on a `.thread` element appearing, which
passed instantly because the reviewer's own reply had already created one — it
proved nothing about the agent's message arriving. It now polls for the content.
Same vacuous-green shape as a -run pattern that matches no tests.

154 browser tests pass (0 skips); unit suite and both golden guards green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
…tion (#191)

Two asks from real use, both consequences of moving questions into the diff.

NAVIGATOR. Anchoring questions to lines gave back the code as context but cost
the "take the quiz" shape: there was no way from question 2 to question 3 except
scrolling and hoping. A sticky strip now carries one small badge per question —
tap to jump, colour says unanswered / right / wrong — plus a link to the
overview and an × to put it away.

It appears on its own whenever the file has a quiz, rather than behind a menu.
Not being able to FIND things has been this feature's repeated failure, so the
navigator itself should not need discovering.

The scroll target is the card's HEAD, not the card. The client centres whatever
it scrolls to, and a question with four options is taller than a phone screen,
so centring the card put the question text off the top and landed the reader on
the options.

COLLAPSE. Questions now count toward the row's annotation badge and fold away
with the comments and suggestions on that line — same badge, same toggle, no new
mechanism. A row carrying only a question still gets a badge, so there is always
a way to fold it and a marker that the line holds something. Answered questions
stay visible rather than auto-collapsing the way a resolved comment does: the
explanation is the payload, and hiding it the instant you answer takes away the
thing you just earned.

Two defects worth recording, both mine, both about how I checked:

1. A STRAY LIST MARKER over the badge numbers — the reviewer saw "a square dot
   which hides the quiz numbers". Same bug as the answer options, in a list I
   added afterwards without applying the fix I had already written a comment
   about. Every quiz list now lives in ONE css rule so a new one cannot skip it.

   The second-order failure matters more: a screenshot HAD shown it, and I
   explained it away as a downscaling artifact after checking textContent, which
   returned "1".."5". A ::marker is not text, so that check could only ever
   confirm the text existed — it could not distinguish "renders fine" from
   "renders with a square on top". The guard is now computed list-style-type,
   which is paint-adjacent and machine-checkable, and I verified it is
   discriminating by reverting the css: it fails naming all five offending
   elements, then passes once restored.

2. TWO COMPETING FRACTIONS. The strip read "1/5 answered" beside a "0/5" chip —
   progress and score, side by side, looking like they should agree. One phrase
   now ("1/5 answered · 0 right"), and the chip says what it does: "Overview".

157 browser tests pass (0 skips); unit suite and both golden guards green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
…croll (#191)

"selection highlight should move with the scroll when a question is in view."

The strip could jump you somewhere but never said where you were: tapping a
badge scrolled the card and left no marker, and scrolling past a question lit
nothing at all. It showed click history, not position.

TWO FIELDS, NOT ONE. Jumping to a question and marking where you are look like
one feature but have opposite lifetimes: the scroll nudge must fire once and
stop, or every re-render yanks the page, while the highlight has to survive
every render or the strip forgets. ScrollToQuizID stays transient;
SelectedQuizID persists. The badge ring is an outline rather than a colour
change, because colour already carries unanswered / right / wrong and
overloading it would destroy that.

FOLLOWING THE SCROLL WITHOUT TOUCHING READ PROGRESS. The viewport reporter that
drives read progress already fires on every scroll with the visible key range,
so the quiz DERIVES the current question from it. Nothing new is reported to the
server and ReadThrough keeps its exact meaning — it is a high-water mark
(furthest ever read) while a spy needs the current bottom, so that value is
recorded in a new field beside it rather than by redefining the existing one.
Changing what read progress means to serve the quiz would have silently moved
the read rail.

PRECEDENCE, which the full sweep caught and the targeted run did not. Adding the
spy broke tapping: viewport position overrode explicit selection, so on a short
file — where every question is on screen at once — tapping badge 3 highlighted
badge 1. Two inputs now feed one piece of state and I had not decided how they
compose. The rule is: an explicit tap wins while its question is still
reachable, then scrolling hands control back to position.

The first attempt at that rule was still wrong: it checked the explicit
selection only against line-ordered questions, which excludes file-level and
ungrounded ones. Those carry no line, so the viewport can neither confirm nor
contradict them, and tapping their badge could never have highlighted anything.
They are honoured whenever explicitly selected.

On the test. The first scroll assertion failed, and it would have been easy to
read that as a broken feature. It was not: the standard fixture's edited.go is
five lines, so every question is visible at once and scrolling cannot change
what is on screen — the test was structurally incapable of passing. It now runs
against setupLongFileRepo, the 150-line fixture the read-progress suite already
had, reused rather than duplicated. Verified discriminating: with the spy
disabled it reports "none" for both scroll positions.

158 browser tests pass (0 skips); unit suite and both golden guards green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
Two reports from real use, and the second exposed a modelling error rather than
a bug in either patch.

CANNOT COLLAPSE A QUESTION. Collapse was built on the row badge, which folds a
whole line's annotations together — consistent with comments and suggestions,
and it works. But a question with no line renders at the FILE HEAD, outside any
row, so it had no control at all. That is the card most likely to be in the way,
since it sits at the top of the file. Every card now carries its own fold, which
composes with the row badge. A folded card keeps its probe and prompt so it
still says what it is about rather than becoming an anonymous stub.

"SCROLL FOLLOWING DOESN'T WORK". It did — until you tapped a badge. The previous
commit honoured an explicit selection unconditionally when the question had no
line, reasoning the viewport "could not contradict it". That is a permanent pin:
tap the file-level badge once and the highlight never moved again, however far
the reviewer scrolled, which is indistinguishable from scroll-tracking being
broken. My test only ever tapped a line-anchored badge, so it never entered that
branch.

The two fixes for this were in DIRECT CONFLICT, and I shipped each in turn:

  "tapping badge 3 highlights badge 3"   (badge 3 is the ungrounded question)
  "tapping the file-level badge must not pin the highlight"

Both concern questions with no line. Honour them unconditionally and the
highlight sticks; ignore them and tapping their badge does nothing. Each patch
traded one bug for the other.

The error was in the framing: I had modelled those questions as POSITIONLESS.
They are not — they render at the file head, which is a position. Once "in view"
means "the viewport is at the top of the file", one uniform rule satisfies both:
a tap wins while its question is on screen, and scrolling hands control back to
whatever is on screen. The special case disappears, and with it the precedence
tuning between two rules that only ever needed to be one.

New tests cover the branches the earlier ones missed: tapping an UNANCHORED
badge and then scrolling, and folding a card that belongs to no row.

160 browser tests pass (0 skips); unit suite and both golden guards green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
#191)

Reviewer: "why did we build a new collapse solution while we already have the
comment and suggestion way of collapsing and expanding?"

Because I mis-scoped the problem. The report was "not able to collapse a quiz
question", and I heard "quiz questions need collapsing" — so I built a quiz-only
fold control with its own state, handler, markup and css. That gave quiz cards
TWO collapse mechanisms (the row badge and the new fold) while leaving the
actual gap open.

The gap was general. `toggleRow` appears zero times in page.tmpl: the file head
has never had an annotation badge, so a file-level COMMENT could not be
collapsed either, and neither could an image-area comment. Only diff rows and
markdown blocks ever could.

So the file head now gets a row key of its own ("file") and renders the SAME
.line-marks badge a diff row renders. toggleRow, ToggledRows, RowToggled and the
collapse css are all reused unchanged. Deleted: CollapsedQuiz state, the
ToggleQuizCard handler, the fold button, its css, and its folded-render branch.
Quiz cards need nothing of their own to collapse.

This also fixes something that predates the feature: file-level and image-area
comments are collapsible for the first time, with the badge counting and
colouring across all three annotation kinds at the head.

One bug caught by the sweep, and it is a good illustration of why the wide blast
radius was worth taking seriously. I gated the whole file-head block on
"FileHeadAnnotationCount > 0" — but that block also contains the AREA COMPOSER,
and when you are starting an area comment there are no annotations yet. Count
zero, composer never rendered, TestE2E_ImageAreaComment hung forever waiting for
it. Gating a container on "does it have content" silently gated the control that
CREATES the content. The wrapper now always renders; only the badge is
conditional.

The test asserts the shared mechanism AND that no quiz-only fold control exists,
so the parallel path cannot be quietly reintroduced.

All 160 browser tests pass across four shards (40 each, 0 skipped); unit suite
and both golden guards green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
Reported: "the badge is on the left instead of on the right. only 1 question
has the badge."

Both trace to how I attached the file-head badge. I reused the diff row's
.line-marks CLASS but not its PLACEMENT. A diff row pins its badge into an
absolutely-positioned right-gutter container (.line-margin, position:absolute
top:0 right); I had made the file-head badge a plain flex child, so it landed on
the LEFT of the cards. It now sits in the same .line-margin container, top-right,
matching every other annotation badge.

"only 1 question has the badge" is the same root cause seen from the other side.
The line-anchored questions DO each have a badge — it lives in the gutter at
their code line, shared per line exactly like a comment's or suggestion's (two
questions on one line share one badge, so 4 line questions on 3 lines show 3
badges). But the badge pins to the top of the row, at the code, while a quiz
card — four options plus an explanation — extends far below it. So scrolled to a
card, its gutter badge is off the top of the screen, and only the file-head
badge, which sits on its own card, was visibly attached. Moving that one to the
right removes the odd-one-out.

Left deliberately unchanged: the gutter badge stays at the code line, consistent
with comments and suggestions. Making it stick to the viewport as you scroll a
tall card would be a real improvement, but it is a change to the SHARED badge
mechanism that affects every annotation, not a quiz fix — not something to do
speculatively on this report.

The test now asserts the badge is in the .line-margin gutter and to the right of
its cards, so the left-placement regression cannot return.

All 160 browser tests pass across four shards; unit suite and both golden guards
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
Reported together: "collapsed badges are not visible. clicking breadcrumbs on
top doesn't expand the collapsed question." Both are one flow, and the
screenshot showed why.

Collapse a question and its card hides behind a badge in the gutter. That badge
is small, and for the FILE-HEAD question it is worse than small — it sits at the
top of the article, directly under the sticky quiz-navigator strip (nav z-index
6, badge z-index 2), so it is painted but occluded. The reliable way back is the
one the reviewer reached for: the breadcrumb, which lists every question and is
always on screen.

But JumpToQuestion only set the scroll + selection; it never cleared the
collapse. So tapping a collapsed question's breadcrumb scrolled to a hidden card
and nothing appeared to happen. It now expands the question's row first — a quiz
card is hidden only while its row carries `row-toggled` (ToggledRows[key] matches
the row's default), so dropping that entry restores the card. The row key is the
file head for an unanchored question, "<line>-<side>" for a line one.

One correctness detail: AnchorStatus is derived (json:"-") and can be zero on a
fresh handler state, so the row-key logic re-grounds first — otherwise a
hallucinated (ungrounded) question, which renders at the file head, would be
mistaken for a line-anchored one and the wrong row cleared.

This makes the navigator the dependable recovery for any collapsed question,
which is the right primary path regardless of how findable the gutter badge is.
The badge still works where it is visible; the breadcrumb is the answer when it
is not.

161 browser tests pass across four shards; unit suite and both golden guards
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
Follow-on to the breadcrumb fix: surface the badge itself, not just give an
alternative recovery path.

The file-head badge was the diff row's absolute gutter badge (.line-margin,
position:absolute top:0). A diff row aligns its badge to a code line, which has
height; the file head has no line, so when the group collapsed (cards
display:none) the wrapper went to zero height and the absolute badge pinned to
the top of the article — directly under the sticky navigator (nav z-index 6 over
the badge's z-index 2). Painted, but occluded: exactly "collapsed badges are not
visible".

It is now an IN-FLOW header row above the cards, right-aligned. In-flow means it
keeps real height and real position regardless of whether the cards below it are
shown, so the toggle stays visible and tappable when collapsed. Confirmed by
screenshot: the badge sits just below the nav strip, top-right, above the diff.

The placement stays "on the right" (the earlier fix's requirement) — a
right-aligned row reads the same as a top-right gutter badge, without the
zero-height collapse failure. The .line-margin absolute container and its
content padding are gone.

The collapse test now also asserts the badge remains laid out (non-zero box,
offsetParent set) while the group is collapsed, so the occlusion regression
cannot return.

161 browser tests pass across four shards; unit suite and both golden guards
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
#191)

A cleanup pass over the quiz surface, which grew across ~15 commits of UI
feedback with helpers rewritten in place. All behavior-preserving — goldens
byte-identical, 161 e2e green.

- Question.LineGrounded() replaces the "is this a head vs a line question"
  predicate (LineAnchored() && !Ungrounded()) that was spelled out at five sites.
  Deliberately NOT folded into the existing Jumpable() (== AnchorStatus "ok"),
  which diverges when AnchorStatus is unset — expandQuestionRow re-grounds
  precisely because that state is reachable.
- Question.EndLine() replaces the ToLine-vs-FromLine clamp duplicated in
  QuizByEndLine and expandQuestionRow.
- QuizCountLines / QuizOpenLines now use the shared row-key helpers that
  CommentCountLines / CommentOpenLines use, matching what their comments already
  claimed. Safe because quiz Side is always defaulted on both load paths.
- FileQuizItems is now the exact complement of the inline set (!LineGrounded())
  instead of a separately-spelled kind=file-or-ungrounded predicate. Identical
  for v1 (the only non-line-grounded kinds emitted are file and ungrounded), but
  it closes a latent gap the simplifier flagged: a future area/region question
  matched neither the inline nor the head predicate and would have rendered
  nowhere. Stating head as "not inline" makes that impossible by construction.

161 browser tests pass across four shards; unit suite and both golden guards
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
Reported: "on scroll breadcrumbs highlight doesn't change." Diagnosed as a
one-render lag — the highlight was computed server-side from the read-progress
viewport reporter, so every scroll was a debounce + WebSocket round-trip +
re-render. Invisible on localhost, laggy on a phone over the tailnet; it settled
~300ms after you stopped but trailed during a continuous scroll.

The fix is the client-side primitive the reviewer asked for, and it turned out to
already exist and already be vendored: livetemplate's lvt-spy scroll-spy (the
same one the TOC uses). Each navigator badge is now
<a href="#qc-<id>" lvt-spy-link>, and lvt-spy toggles lvt-active on whichever
badge's question card is on screen — a rAF-throttled client scroll listener, zero
server round-trip, so it tracks without lag.

Two things fell out, both replacing server round-trips with platform features:

- SCROLL to a question is now the native anchor (href="#qc-<id>"), instant, with
  scroll-margin-top reserving the sticky nav's height.
- EXPAND a collapsed question on breadcrumb tap is now CSS :target — the hash the
  anchor sets force-shows the addressed card even inside a collapsed row. No
  JumpToQuestion handler, no expandQuestionRow.

This DELETES the entire server-side scroll-spy: currentQuizID and its
tap-vs-scroll precedence, viewportLines, headQuestions, firstDiffLine,
SelectedQuizID, ScrollToQuizID, the LastViewBottomKey field and its ReportViewport
write, JumpToQuestion, expandQuestionRow, and the QuizItem.Current/ScrollTo
fields. The read-progress reporter is left exactly as it was — this feature no
longer piggybacks on it.

All 160 browser tests pass across four shards (the obsolete
tap-precedence-doesn't-stick test is removed — there is no precedence to test now,
the spy simply reflects what's on screen); unit suite and both golden guards
green. The nav e2e now assert lvt-active (client) and the :target reveal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
#191)

Reported: "Are the questions not sequential from top to bottom? I'm at the
bottom and the highlighted question number is three."

The badges were numbered by the agent's JSON order (Num = QuizItems index), which
has no relation to where the questions sit in the diff. So at the bottom of the
page, the last question by position could be badge 3 — the badges did not read
1..N top to bottom.

QuizItems now orders itself by DOCUMENT position — head questions (which render
above the diff) first, then line-anchored ones by line — and numbers 1..N in that
order. The navigator strip, the overview list, and the file-head group all follow
it. So badge N is the bottom question, and the strip reads left-to-right the way
the page reads top-to-bottom.

Two follow-ons needed to make "at the bottom, badge N is active" actually hold:

- The spy trigger line moved from 7rem to 40vh, so the active question is the one
  you are READING (mid-screen), not one that has scrolled nearly out of view.
- "Scroll beyond last line" padding (the pattern VS Code and docs sites use) below
  a quizzed diff, so the LAST question can scroll up to the 40vh trigger and light
  its badge — without it the last card tops out mid-screen at max scroll and its
  badge never activates. Scoped to `main.viewer > article[lvt-spy]` (higher
  specificity than the `main.viewer > article { padding: 0 }` reset it has to beat).

New e2e asserts badge numbers increase with the card's vertical position and that
the bottom of the page activates the last badge.

161 browser tests pass across four shards; unit suite and both golden guards
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHkKEgLvjwvK9EEprdtAjp
@adnaan
adnaan merged commit 624d2c7 into main Jul 22, 2026
11 checks passed
@adnaan
adnaan deleted the issue-191-quiz branch July 22, 2026 12:23
Sign up for free to 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