The Sentence Selection post ended with a line I get to walk back today: “the punctuation heuristic is the whole rule, by design.” It was the right call for a first slice, and the wrong one long-term — an Instructor writing “Sr. Scrum Master” or a passage with abbreviations has no way to tell the parser “not here.” Three small, independent upgrades landed together: manual || markers replacing the auto-split, instructor-authored completion messages, and an image button on the Prompt.
|| replaces the punctuation guess
The old split/1 cut on ., !, ? followed by whitespace — invisible, and wrong exactly when the Instructor’s prose didn’t cooperate. The new one is one line, and the Instructor decides:
def split(text) when is_binary(text) do
text
|> String.split("||")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
Typing || by hand works, but nobody wants to retype an entire passage’s worth of markers from scratch. Two authoring aids do the typing instead:
- Split here — a toolbar button that inserts
||at the textarea’s cursor position, entirely client-side. - Split on every — pick a delimiter (
.,,,!,?, or a line break) and Apply rewrites the whole marker set: every existing||is stripped, then reinserted after each occurrence of the delimiter. It’s the old auto-split, reframed as something explicit and inspectable instead of an always-on guess — and if the passage already has markers, Apply asks first:

def split_on_every(text, delimiter) do
stripped = String.replace(text, "||", "")
case delimiter do
"\n" ->
stripped
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n|| ")
delim ->
stripped
|> String.split(delim)
|> Enum.join(delim <> "||")
|> strip_trailing_marker(delim)
end
end
A passage with fewer than 2 non-empty fragments can’t be saved — a single-fragment “exercise” isn’t one — alongside the existing “mark at least one Sentence correct” rule:

Instructor-authored completion messages
Sentence Selection used to say exactly one thing on a wrong Check: “You made N mistakes.” Now the Instructor writes both sides, the same way a Quiz Question already carries explanation_correct / explanation_wrong:

def completion_message(feedback_correct, _feedback_mistakes, :correct) do
blank_to_default(feedback_correct, "Nice work — you got every Sentence right.")
end
def completion_message(_feedback_correct, feedback_mistakes, {:mistakes, _count}) do
blank_to_default(feedback_mistakes, "Not quite — give it another look and try again.")
end
It’s keyed on the aggregate outcome only — :correct vs. {:mistakes, n} — never on which Sentence was right or wrong. ADR 0014 (the answer key stays server-side) doesn’t move: the message is a string the Instructor wrote in advance, not a report on the specific attempt.


Images in the Prompt
The Prompt already rendered markdown through the same Earmark pipeline as a Text Lesson body, so  always worked — this is just a button instead of a hand-typed URL, reusing ImageUploadLive exactly as the Text Lesson editor does.
sequenceDiagram
participant I as Instructor
participant U as ImageUploadLive
participant H as CursorInsert (JS hook)
participant TA as Prompt textarea
I->>U: choose a file
U->>U: upload to Storage
U-->>H: push_event("insert-markdown", markdown, target_id)
H->>TA: insert at cursor, dispatch input
TA-->>I: markdown visible immediately
That target_id field is the one generalization this needed: the same hook now also handles the Split here button (data-cursor-insert="||", data-target-id=), and a server push carries an explicit target instead of a hardcoded element id — because this editor has two independent textareas (Prompt, Passage) that can each receive an insert, and previously there was only ever one.

What the browser caught that the tests didn’t
ImageUploadLive renders its own <form> internally, and a form can’t contain another form — so the Prompt, Passage, and feedback fields all had to move outside the editor’s single <.form>, joined back in only by a form="ss-form" attribute (the same pattern the Text Lesson editor already uses for its body textarea).
That restructuring quietly broke something the tests didn’t catch, because they don’t drive real per-keystroke events: each field’s phx-change now fires as an isolated single-field event, carrying only its own key. On a brand-new, unsaved Sentence Selection, typing a title and then editing the Passage silently reset the title back to blank — the changeset’s baseline (%Lesson{}) has no saved value to fall back to for a field the current event didn’t mention.
def handle_event("validate", %{"sentence_selection" => params}, socket) do
changeset =
Lesson.sentence_selection_changeset(
base_lesson(socket),
Map.merge(current_params(socket), params),
socket.assigns.correct_indices
)
{:noreply, assign_form(socket, changeset)}
end
Merging each incoming delta onto the last known value of every field — instead of trusting the isolated event to carry the whole picture — fixed it. A Playwright run driving the real editor (fill title, upload an image, use both split tools, mark a Sentence, hit the validation error, then Publish) is what surfaced it in the first place: the final save event logged an empty title even though I’d typed one minutes earlier. mix test’s LiveView tests pass hand-built full-attribute params straight to form/3 and never noticed, because that’s not how a real keystroke behaves.
erDiagram
LESSON ||--o| SENTENCE_SELECTION : "sentence_selection (jsonb)"
LESSON {
string prompt "markdown, images included"
string feedback_correct
string feedback_mistakes
}
SENTENCE_SELECTION ||--o{ SENTENCE : "|| split"
SENTENCE_SELECTION {
string source "raw passage, || markers intact"
}
SENTENCE {
string id "s1, s2… stable within a save"
string text
bool correct "NEVER rendered"
}
What’s deferred
- A migration path for old auto-split lessons — moot, since none existed yet in production.
- Markdown/images in the Passage itself — Prompt only, for now; the Card was explicit about that scope.
- A single distinctive Unicode marker (
‖) instead of||— considered and rejected:||is plain ASCII, safe in diffs and fixtures, and matches Word Match’s two-character[[ ]]convention already.