The previous post built the authoring screen for a Grouping Lesson and left the Student looking at a placeholder that said, honestly enough, this exercise is not playable yet. This is the deck.

The Deck is dealt one Card at a time into the Hand. You read the Card, tap the Category you think it belongs to, and it flies there. When the Deck is empty and every Card is placed, you Check — and only a perfect Deck completes the Lesson.

The Deck mid-round, one Card in Hand and three Categories waiting

The round runs on the server

Everything in that description is animation: cards shuffle, flip, fly across the screen, spin to a verdict, shake when they are wrong. So the obvious design is to hand the client the deck and let it own the whole round, pushing only the final placements up to be judged.

That was the initial recommendation. It was rejected, and the reasons were specific to this repository rather than general.

There is no package.json here. Assets are built by the esbuild hex package over vendored JS, so there is no dependency manager to hang a test runner on. And assets/js/app.js was 459 lines for the entire product, spread over eight hooks of ten to forty-five lines — every one of them DOM glue (focus, auto-dismiss, a confirm dialog, two Paddle wrappers) and not one owning application state.

A client-owned round would have been three to four hundred lines of stateful JavaScript: roughly doubling the product’s JS, and introducing the only hook that holds a model. Testing it would have meant adding npm, node_modules and a jsdom runner — which would have bought almost nothing, because getBoundingClientRect, CSS transitions, animationend and hit-testing are precisely what jsdom stubs to zero. The last several browser-only bugs in this codebase were visible only to document.elementFromPoint. The tests would have gone green on exactly the failures that actually occur.

So the round is server-held (ADR 0023). Deal order, the Card in Hand, the placements and the phase all live in the LiveComponent’s socket assigns, exactly as Word Match holds its placements. Every tap is a phx-click.

sequenceDiagram
    autonumber
    participant S as Student
    participant H as GroupingDeck hook
    participant L as LiveComponent (server)
    S->>H: click a Category
    Note over H: clone the Card in Hand,
pin the clone to body,
fly it to the Category H->>L: phx-click place Note over L: placements = Map.put(...)
draw the next Card L-->>H: patch: new Card in Hand Note over H: hold it hidden until
the flight lands H->>H: fly the next Card off the pile Note over H: reveal, flip

What makes this work at all is that a LiveView patch cannot disturb the animation. The design’s flyTo() already clones the element, fixes the clone to viewport coordinates and appends it to <body> — it animates a detached ghost, so it does not care that the real node is being replaced underneath it. The latency then hides inside an animation that was going to be played regardless.

The hook was predicted at sixty to ninety lines. It landed at about a hundred and five, and the extra third is all sequencing — which is worth its own section further down, because getting it wrong is what the round looked like for the first hour.

The answer key never reaches the browser

ADR 0014 is the rule that no Active Lesson ever ships its answers to the page. A server-held round makes that nearly free: the page is sent Card ids, Faces, Details and the Categories with their colours, and nothing about which Card belongs where.

It goes further than the rule requires, almost by accident. Because the Deck is dealt one Card at a time, the Cards you have not been dealt yet are not on the page at all. The draw pile is rendered as anonymous backs. Verified in the browser at the start of a round:

REMAINING_DECK_NODES   11
FACES_RENDERED         ["go"]
HTML_HAS_CATEGORY_KEY  false

Eleven card backs, one Face — the one in your hand.

There was one leak in the design itself, and it was easy to miss because it looks like a progress indicator. Each Category header renders 0 / 4, where the denominator is cat.itemsPerCatthe true number of Cards belonging to that Category. That hands over the answer distribution before a single Card is placed, and makes the last few Cards deducible by elimination: once two Categories are full to their stated size, everything left has exactly one home.

So the header shows the placed count with no denominator. The overall progress bar keeps placed / deck size, because how big the Deck is was never a secret.

Four, three and four placed — counts with no denominator — and a Card’s Detail on keyboard focus

That screenshot is also the other half of a criterion: a placed Card shows its Detail on hover and on keyboard focus. A pointer-only tooltip would leave the keyboard path reading a bare Face with none of the context needed to decide where it goes — so it is plain CSS on :hover, :focus, counter-rotated out of the Card’s own scatter angle so the tip hangs straight.

What a wrong Check reveals, and what it refuses to

Every placed Card flips to ✓ or ✗, and the modal shows correct / total.

Eleven of twelve, one Card marked with a red cross

The design mock also listed the corrections — went → was in Presente, correct: Passado. That was dropped. A Grouping completes only on a perfect Deck, the same rule Quiz, Word Match and Sentence Selection already follow, so telling you where a Card belonged turns the next round into transcription.

This is a deliberate step beyond the Word Match precedent, which reports only a count of mistakes and never which ones. Here you learn which Cards are wrong, never where they belong. The split shows up directly in the domain:

# Server-side only — never render this whole. It names the *right* Category of
# every Card, which is exactly what a wrong Check refuses to tell the Student.
def verdicts(document, placements) do
  for {card, category} <- cards_with_category(document) do
    response = Map.get(placements, id(card), "")
    %{card_id: id(card), response: response, correct: response == id(category)}
  end
end

The component filters that down to %{card_id => correct?} before anything is rendered. The full structure has exactly two callers: the verdict map, and the Analytics extractor.

The same mock offered Concluir at 5/12. Since 5/12 does not complete anything, that button is now what it actually does: Back to course — leaving without completing.

A setting that finally means something

The authoring post ended on an open question: two settings had shipped that did nothing, and if the deck arrived without either ever being switched off, they were to be deleted.

shuffle survives unchanged. allow_moving — “allow moving placed Cards” — was describing a rule that does not exist. Pulling a Card back before a Check is always allowed, under any setting: nothing has been judged, so that is correcting a mis-tap, not conceding one.

What actually needed a switch was what survives a wrong Check. So it was renamed keep_correct, Keep correct answers between attempts:

The Settings tab with the renamed toggle

On, a wrong Check keeps the ✓ Cards placed and returns only the ✗ Cards to the Hand. Eleven right, one wrong, and here is the next round:

Eleven Cards still placed, only “went” back in the Hand

No migration. The new key defaults on, and settings/1 already read a missing setting as on, so a document written before the rename simply reads as the default and the next save drops the dead key.

test "keep_correct reads as on for a document written before the rename" do
  legacy = %{"categories" => [], "settings" => %{"shuffle" => false, "allow_moving" => false}}

  assert Grouping.keep_correct?(legacy)
  refute Grouping.shuffle?(legacy)
end

There is one place the acceptance criteria had to be read against themselves. They say the ✗ Cards come back “reshuffled” — but read literally that overrides an Instructor who turned shuffling off, on the second round of the very Lesson they configured. The retry re-deals through the same shuffle setting as the first deal. That is the only reading under which both criteria stay true at once.

Analytics had three hardcoded type lists, not one

The refinement predicted one trap, and it was real. Analytics.active_lessons/1 selects a bare map carrying word_match: and sentence_selection: keys and no grouping: key. Adding :grouping to @active_types without growing that select raises KeyError in challenges_of/2 the first time a published Grouping is enumerated.

This is precisely why the item licensed exactly one integration test. A pure test of challenges_of/2 passes regardless — it is handed a map that already has the key. A mocked Repo would be green too: a mock returns the shape you told it to return, including the key you forgot.

The test found it immediately. Then it found a second one nobody had predicted:

where:
  (l.lesson_type in [:word_match, :sentence_selection] and
     a.inserted_at >= l.updated_at) or
    (not is_nil(q.id) and a.inserted_at >= q.updated_at)

That is the last-edit window (ADR 0017). A type missing from it is not a crash. Every Attempt is silently filtered out and every Card reads as never attempted — a wrong number that looks entirely plausible.

And a third, which turned out to be a small feature rather than a bug. response_text/2 resolves what the log stored into something an Instructor can read. A Word Match stores the placed word; a Quiz stores an option id and resolves it against the Question’s options. A Grouping stores the id of the Category the Card was placed in, so it needed the same treatment — the Categories travel with the Challenge and get resolved at the last moment.

The payoff is the most useful line on the screen:

Analytics listing all twelve Cards, with “went” at 67% wrong and “Presente” as the most-picked wrong answer

went — 67% wrong rate — most-picked wrong answer “Presente”. The Student is never told where a Card belonged. The Instructor is told where it keeps being put.

Three dispatch sites for one Lesson type is the argument for the refactor already filed as 0165-active-lesson-lifecycle, which will give Forgia.Lessons.Type the lifecycle callbacks it is missing. This item deliberately ships the fourth copy so that generalisation has four real examples instead of three and a guess.

Three bugs a green test suite could not have found

The drop target shrank as the fan filled it. Each Category is a real focusable control — Tab to it, press Enter or Space, and the Card in Hand goes there. Scoped to the Category’s body, that control is progressively covered by the Cards already placed in it, which are pull-back controls in their own right. At four Cards the middle of the box was all mini Card, and the tap the design asks for landed on a pull-back.

The place button now covers the entire Category box, underneath both the header and the fan, which pass their clicks straight through:

.gr-container-head,
.gr-container-body { position: relative; z-index: 1; pointer-events: none; }
.gr-mini           { pointer-events: auto; }

Only the placed Cards take their clicks back, deliberately. The DOM was well-formed the entire time; render_click was green throughout.

Ghost flights landed up and to the left. A flying clone is translated so its top-left meets the target’s top-left, then scaled down. Scaling runs about the default centre origin, so the ghost ends up pulled back by half of however much it shrank — an error proportional to the size difference, which is why the small deck card missed the large hand slot by roughly 40% of a card. One line:

transformOrigin: "0 0",

Landing error after: dx=0 dy=0.

A class the server renders cannot be retired by the client. The shuffle — the splay where the deck fans out and gathers itself — is CSS gated on a gr-shuffling class. The first version rendered that class in the template and had the hook remove it once the splay had played. It never stuck: morphdom re-applies the attributes it is given, and the template always says the same thing. Every patch that touched the pile put the class straight back.

The symptom was oddly specific. Pulling a Card back puts the Card that was in your Hand onto the top of the Deck — a brand-new DOM node — which then replayed the entire shuffle, reading as though the Card you just took were being filed back into the Deck. Counting animationstart events on deck cards told the story:

                before   after
ROUND_START        11       11    ← the splay should play here
AFTER_PLACE        20        0
AFTER_PULLBACK      2        0
AFTER_RESTART      11       11    ← and again on a new round

The class is now added by the hook and never rendered. Client-owned state must be client-added — which is also the right side of the ADR 0023 line, since it is purely motion.

The hook owns when, not just how

That last fix is a specific case of the thing ADR 0023 did not anticipate. Because the server sends the next Card the instant it is dealt, the hook has to decide when it becomes visible.

Left alone, the next Card flips in while the Card you just placed is still flying, and the deal ghost slides over a Card already sitting in the slot. Both of those were reported as “it looks wrong”, and both were the same cause. So a newly dealt Card is hidden — with its flip animation paused — until the ghost carrying it lands:

.gr-hand-card.gr-dealing { visibility: hidden; }
.gr-hand-card.gr-dealing .gr-hand-inner { animation-play-state: paused; }

The animation is both-filled, so paused at 0% it is simply the card back. Measured on one placement:

GHOST PLACE  launched@30ms     (flight ends at 470ms)
GHOST DEAL   launched@472ms    ← after, not alongside
hand hidden  80ms ✓  250ms ✓  500ms ✓  750ms ✓  1000ms ✗

Holding a Card back opens a window in which a fast Student could place a Card that is still invisible — and the ghost would clone the hidden state and fly blank. Three placements at 120ms intervals, well inside the hold, proved the guard: six ghosts, all visible, three Cards placed, nothing stuck.

None of this is negotiable per Student, incidentally. prefers-reduced-motion is an operating-system preference people set for themselves; only those Students get the motionless path, and it is complete — the CSS zeroes every duration and the hook appends no ghosts at all.

What is tested where

Forgia.Courses.Grouping gained the whole round as pure functions — the deal, the verdicts, check/2, the retry rule, the deterministic scatter angle — so it all tests async: true with no database:

test "keep_correct on: the correct Cards stay placed and only the wrong ones are re-dealt" do
  {deck, kept} = Grouping.next_round(document, placements)

  assert Enum.map(deck, &Grouping.face/1) == ~w(went)
  assert map_size(kept) == 3
end

Extractor.extract/2 is documented pure, so one-row-per-Card, response-is-the-Category-id and the correct flag all test without a database too. Exactly one test earns DataCase, and the acceptance criteria named it in advance: a published Grouping’s Cards appear in Analytics.list_challenges/1. It is the only place the KeyError and the missing window clause could have been caught.

Then the whole thing was driven in a real browser: the deal, the flights, the keyboard path, a wrong Check, the retry, the Completion, restart, replaying a completed Lesson, reduced motion, and 320px. Zero JS errors, scrollWidth exactly 320.

Twelve of twelve, and the Lesson complete

Nothing about the round is persisted. Navigate away mid-deck and you come back to a fresh Deck — the same as Word Match and Sentence Selection today. That is also what makes a question the authoring item left open simply not arise: there is no stored round to go stale when an Instructor edits the Lesson. Save-and-resume is real work — a persisted run and a staleness snapshot, as QuizAttempt already does for the Quiz — and it is the next item but one.