The Student’s Quiz now runs on printed paper. Half-tone screen, two-pixel ink outlines, offset shadows, a progress track with a flag that hops one tick per Question, a verdict stamp in the option’s own row, and one burst of confetti when the Quiz is finished.

It is the same vocabulary the comic Sentence Selection already speaks, so the two Challenge types no longer look like they came from different products.
“Layout” was doing a lot of work
The Card asked for “a game-like layout with neat animations (the quiz-arcade.html design)”. The design it named is not a layout. It is a different game.
In quiz-arcade.html you get one shot per Question. Answer wrong and the option locks, the correct one lights up, and a timer carries you to the next Question whether you like it or not. At the end, a modal with three stars, a hit count, an error count and a best streak.
Our Quiz is try-until-correct. You pick until you get it right, then you click Next question yourself. Every Student finishes at 100%, by construction.
Port the design faithfully and you have not restyled the Quiz — you have replaced it, and quietly answered a product question nobody asked: can a Student now fail a Quiz? That question reaches into Completion, into Progress, into what a Certificate means. It deserves its own Card and probably a PDR. It does not deserve to arrive as a side effect of a CSS port.
So the stars, the streak chip, the result modal and the auto-advance timers stayed out. (The previous item hit the same wall from the same direction — a prototype that specified more than it was asked to. Two in a row is a pattern worth naming: a working prototype is an argument, not a specification.)
One exclusion was not a preference. The design highlights the correct option after a wrong pick:
.opt.hint {
background: var(--moss-100);
border-color: var(--moss-600);
}
In a one-shot quiz that is a decent affordance. In try-until-correct it ends the exercise — the Student reads the answer off the screen and clicks it. Same reasoning as ADR 0025, which refuses to mark unselected Sentences in a Sentence Selection.
The part the test suite cannot see
Here is the uncomfortable thing about this item: the deliverable is motion, and ExUnit cannot see motion.
LiveViewTest renders markup. It never lays out a page, never hit-tests a coordinate, never runs a phx-hook, never fires a keyframe. A test suite can be entirely green while the flag twitches, the card flinches, the confetti never fires and a button is unclickable because something invisible is sitting on top of it. This codebase has been bitten by exactly that before: a daisyUI class collision collapsed a label to 21×21, its text spilled over the Save button and ate the clicks, and render_submit stayed green while the real button did nothing.
So the question for this item was not “how do I test the animations” — you cannot, not here. It was: what is the testable surface underneath them?
The answer turned out to be the DOM ids, because of how the animations have to be built in the first place.
Why the ids are the animation
The obvious way to replay an entry animation is to toggle a class. In LiveView that does not work. A class the server renders is re-asserted on every patch that touches its element — the template always says the same thing, so morphdom always puts it back. The Grouping deck’s shuffle learned this the hard way: the splay replayed every time a Card returned to the pile.
The way that does work is to make the element new. morphdom keys on id; change the id and it discards the node and builds a fresh one, and a fresh node runs its CSS animation exactly once, for free, with no hook involved.
So the Question card’s id is the Question’s id:
<div class="quiz-card" id={"quiz-q-#{question.id}"}>
Advance and the id changes, the node is replaced, quiz-card-in plays. Answer wrong and the id is unchanged, the node is patched in place, and the card stays put under the feedback instead of flinching on every wrong guess.
The progress flag needed both behaviours at once, which is the nice bit. It has to slide smoothly along the track — which requires the element to survive the patch, or there is nothing for transition: left to animate from — and it has to hop on arrival, which requires it to be replaced. One element cannot do both. Two nested elements can:
flowchart TD
A["Student picks the correct option"] --> B["answered_count/2 goes 2 → 3"]
B --> C["template re-renders the track"]
C --> D["<span class=quiz-runner style=left:75%>
no id — morphdom patches in place"]
C --> E["<span class=quiz-flag id=quiz-flag-3>
id changed — morphdom replaces the node"]
D --> F["CSS transition animates left
50% → 75%"]
E --> G["fresh node runs quiz-hop
exactly once"]
F --> H["flag slides and hops together"]
G --> H
The outer .quiz-runner carries no id and owns the position. The inner .quiz-flag is keyed on the answered count and owns the hop.
Keying the flag on the Question index — which is what the acceptance criteria originally said — would have been subtly wrong. The track fills the moment you answer correctly, not when you click Next. An index-keyed flag would have slid on the pick and hopped one step later, on the advance. The two motions would have come apart by a click.
That is what the ExUnit tests actually assert. Not that anything moves — that the ids are right:
test "advancing changes the Question card id, so the entry animation replays"
test "a wrong pick leaves the Question card id untouched"
test "the runner keeps one id across Questions so its position transitions"
test "the flag id follows the answered count, so the hop re-fires"
Plus the track’s aria contract, the confetti hook being mounted on the completion panel and nowhere else, and a sweep proving no bare opt, track, verdict, card or badge class token reaches the DOM — the collision guard, at the one place a collision could enter.
Then the browser found three things anyway
Thirty-four checks in a real Chromium, counting animationstart events and measuring bounding boxes. Three of them caught defects that every green test in the suite had missed.
The flag hopped on page load. The keyframe sat unconditionally on an element that always mounts, so opening an unstarted Quiz greeted the Student with the flag flinching in place at the left end of the rail. The measurement is what makes it undeniable — at rest the flag is 24×24; captured on arrival it was 29.6×24, caught mid-keyframe at scale(1.08). The design suppresses this explicitly (if (idx > 0) runner.classList.add('hop')) and the port had dropped it.
prefers-reduced-motion missed the in-flight spinner. While a pick is on its round-trip, the option’s marker spins. That is an infinite rotation — precisely the thing the setting exists to stop — and the media query listed every arcade keyframe but not that one. It now stops, while the marker keeps its clay fill and the “Checking…” tag is shown outright instead of fading in, so the pick still visibly registers.
And fixing the first one nearly broke the second. The hop moved behind a .hopping class, which meant the reduced-motion rule had to move with it:
@media (prefers-reduced-motion: reduce) {
.quiz-flag.hopping { animation: none; } /* not .quiz-flag */
}
A media query adds no specificity. .quiz-flag is one class, .quiz-flag.hopping is two, and the two-class selector wins inside the media query as readily as outside it — so the obvious rule would have left the hop running for exactly the people who asked for it to stop. It was caught because the browser checks were re-run after the fix rather than assumed.

Two of the failures were mine, not the app’s
Worth saying plainly, because a verification script is code and code is wrong at the usual rate.
The first run reported card-in fires 0×. The animation was in fact firing perfectly. waitForFunction resolves on the tick the node is inserted, and animationstart does not fire until the next frame — the log was read one frame too early. A 250ms settle, and it reads 1×.
The second reported that no option was reachable by Tab. The tab order was fine: three tabs. The keyboard pass reused the same Student who had just finished the Quiz in the previous pass, so it landed on the review panel, which has no options to tab to at all. Each pass now gets its own Quiz.
Both looked exactly like product bugs in the output. Neither was. The lesson is not “do not write verification scripts” — the script found three real defects that 1,578 ExUnit tests did not. It is that a failing check buys you an investigation, not a conclusion.
What shipped
All four modes wear the skin, so finishing a Quiz never drops the Student from printed paper onto a plain white card. Review matters most here — it is the state a returning Student sees every time.

The Quiz’s behaviour is untouched. Try-until-correct, Completion on finish, Retake, resume, stale-quiz reset — all of it verbatim. The thirteen existing tests that guard that behaviour pass unchanged, and that was the real acceptance criterion: if any of them had gone red, the skin had leaked into the mechanic.
The stepper retired, the track took over progress, and the header stopped repeating a count the track already shows.
.marker became .quiz-marker on the way past. Not a daisyUI component — just generic enough that leaving it bare was borrowing trouble from a library that has already cost this codebase one silently unclickable button.