Forgia’s Active Lesson types have all had one shape in common: the Instructor types a passage into a textarea, and a parser turns it into a document. A Word Match is one delimited passage. A Sentence Selection is one passage split on ||. Both are authored in a minute, and both refuse to save when the passage is malformed — which costs nothing, because you are a minute from valid.

Grouping is not that. It is up to four named Categories holding up to forty Cards, each with a required Face and an optional Detail, built up over a long sitting. This slice is the authoring screen only: what the Student eventually does with the deck — the deal, the flip, the drop, the check — is a separate item.

The Categories and Cards tab, with two Categories and four Cards

A draft is allowed to be wrong

The decision that shaped everything else was made before any code: the authoring rules gate Publish, not Save.

word_match_changeset and sentence_selection_changeset both refuse to write a malformed document regardless of which button was pressed. Copying that here would have been the consistent choice and the wrong one. A Grouping is frequently left half-finished on purpose — one Category named, three Cards in, the rest still in your head. A strict changeset in that situation does not produce an error message. It produces lost typing.

So a draft always writes: one Category, an empty Category, a Card with no Face at all. That is ADR 0022. The safety argument is that a draft is unreachable by Students — status gates that — so an incomplete one endangers nobody. The only code that must tolerate it is the render path, and the Student-facing deck has to be defensive about a malformed document anyway, because a Lesson can always be edited after it is published.

The cost we accept is that the rules live in two places rather than one: a permissive changeset for the write, and a separate issues/2 for the judgement.

flowchart TD
    A[Instructor presses a footer button] --> B{Which button?}
    B -->|Save as draft| C[Write, whatever state it is in]
    B -->|Publish| D["Grouping.issues/2"]
    D -->|empty| E[Write with status published]
    D -->|non-empty| F[Refuse, naming every unmet rule]
    G[Editor render] --> D
    D -->|non-empty| H[Disable Publish
list the rules in the panel]

Note where the arrows converge. issues/2 is called by the editor to decide whether the button is disabled, and by the save path to decide whether to refuse. It is the same function, so the button state and the server check cannot drift apart. A Publish that arrives past a disabled button — a replayed POST, a tampered DOM — is refused with the identical list of reasons the panel was already showing.

The validation panel listing every unmet rule, with Publish disabled

That panel is also the whole error-reporting strategy. There are no per-field error messages, because in a permissive draft nothing the Instructor typed is an error — it is just work that isn’t finished. The panel says what publishing will need; the tinted inputs say where.

Distinct by construction, not by validation

Four Categories at most, four palette colours. Since colours are never scarcer than Categories, distinctness is always reachable — which means it never has to be a validation message.

Picking a colour a sibling already holds swaps the two:

cond do
  id(category) == category_id -> Map.put(category, "color", color)
  color(category) == color     -> Map.put(category, "color", previous)
  true                         -> category
end

An Instructor cannot produce an invalid arrangement from the swatch row, so there is nothing to tell them about. This is the difference between an invariant and a rule: a rule is checked and reported, an invariant simply cannot be violated. The test that matters is the one asserting no sequence of choices ever leaves two Categories sharing a colour.

Card ids are minted, never derived

A Grouping’s Challenge — the atom a Student can get right or wrong — is a Card. Nothing is played yet, so no Challenge Attempt is written in this item. But the ids that Analytics will one day key on are minted here, and how they are minted is already decided.

Every Card gets an id at creation, and it is never re-derived from position. Delete the 2nd of 5 Cards and the other four keep the ids they had.

erDiagram
    LESSON ||--|| GROUPING : "one jsonb column"
    GROUPING ||--|{ CATEGORY : "2..4"
    CATEGORY ||--o{ CARD : holds
    CATEGORY {
        string id "minted at creation"
        string name "Instructor-supplied"
        string color "clay | moss | slate | amber"
    }
    CARD {
        string id "stable — never positional"
        string face "required to publish"
        string detail "optional, revealed on flip"
    }

This follows ADR 0007, which established stable ids for Questions and their options. It also deliberately avoids repeating what ADR 0017 records: Word Match Blank ids are positional, so inserting a Blank silently reattributes a neighbour’s failure history. Grouping does not make that trade, so its Analytics stay comparable across an edit.

The whole document is one grouping jsonb map, string-keyed throughout — the sixth per-type column on lessons. Categories and Cards have no identity outside their Lesson and are edited as one block, so a table would buy nothing. ADR 0020 already accepted a widening lessons table as the price of keeping Lesson types additive; this is not a new argument.

Three bugs a green test suite cannot find

All 1088 tests passed before any of these were known. Every one was found by driving a real browser, and every one cost the Instructor their typing.

Enter also submitted the form. The design adds a Card by typing into a box and pressing Enter. That works — and it also triggers the browser’s implicit form submission, because the form has submit buttons in its footer. So adding a Card from the keyboard saved the whole Lesson and redirected, moving the page out from under the Instructor mid-sentence. phx-keydown does not suppress that; only preventDefault does.

The fix has a second trap inside it. The hook also clears the box, and clearing it synchronously breaks the feature outright:

if (e.key !== "Enter") return
e.preventDefault()
// Clearing here would clear it too early: this listener is on the element
// and LiveView's is delegated to the document, so it reads `el.value`
// *after* us and would send an empty Face. Defer a tick.
if (this.el.value.trim() !== "") setTimeout(() => (this.el.value = ""), 0)

render_keydown dispatches exactly the one event it is asked to dispatch. It never performs the browser’s implicit submission, so ExUnit cannot see any of this.

Inputs in a repeated list had no stable DOM ids. Add a Card and LiveView’s patching, matching positionally, re-keys the rows after it — and overwrites whatever was half-typed in one of them with the value the server last rendered for a different row. Keying every input off the record’s own stable id fixed it, which is a nice second dividend from minting those ids in the first place. There is a corollary worth knowing: LiveView deliberately never overwrites a focused input, which is what stops a patch eating your keystrokes — and also why the add-a-Card box kept its text after the server had already cleared the draft.

The flash toast ate the clicks on Publish. .mm-toast is position: fixed; bottom: var(--sp-6); right. A pinned footer bar is at bottom: 0; right. They occupy the same corner, and document.elementFromPoint at the centre of the Publish button returned div.mm-toast-body. The DOM was flawless; the button was simply unreachable.

const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
const ok = hit === el || el.contains(hit);

That hit-test is now part of the verification recipe. LiveViewTest never lays anything out and never hit-tests, so render_submit stays green while the real button does nothing — the same failure mode that once stopped the Lesson editor’s Save button, from a different cause. Here the fix lifts the toast clear of the footer, scoped with :has(.gr-footer) so no other screen’s toasts move.

A fourth, smaller one: the first save never left /grouping/new, because the code asked whether it was a create after the assigns had been replaced with the new Lesson. It compared the new record against itself and always concluded “no”. The socket saved correctly, so nothing was lost — but reloading that stale URL would have started a blank Grouping over the top of the one just written.

What is tested where

Forgia.Courses.Grouping is pure data and pure functions, so 48 tests run async: true with no database in sight — the Publish rules, id stability, the colour swap, and the param normalization.

That last one can only be tested there. LiveView mixes _unused_<field> markers into index-keyed params to flag inputs nobody has touched, which means the params map can be larger than the row count, and taking a marker for a row conjures a phantom Card on every keystroke. LiveViewTest’s form helpers never emit those markers, so a test driven through the form passes whether or not the case is handled. It is asserted against the normalizing function directly:

test "drops LiveView's _unused_ markers rather than conjuring a phantom row" do
  params = %{"0" => "a", "_unused_0" => "", "1" => "b", "_unused_1" => ""}
  assert Grouping.ordered_rows(params) == ["a", "b"]
end

The remaining 28 tests are integration by necessity, and were named as such in the acceptance criteria before anyone wrote them: the jsonb round-trip and the draft/publish form round-trip cannot be asserted any other way. They drive render_change then render_submit on the tracked form element and assert against the database, because hand-crafted params passed straight to render_submit bypass the form and have passed green while the real screen lost data.

Then the whole flow was driven in a real browser — the type picker, all three tabs, adding Cards by keyboard, the colour swap, Publish, and reopening to prove every Category, colour, Face, Detail and setting came back exactly as left.

Reopening the published Lesson: everything as it was left

The two settings — shuffle Cards at start and allow moving placed Cards — persist and round-trip, and do nothing at all yet. They are inert until the deck reads them. If that ships and neither has ever been turned off, they get deleted.