The previous item built the cast and put nobody on stage. This one puts somebody on stage.
A Sentence Selection shows the Student a Passage and a Prompt — find every sentence that describes a PO accountability — and the Student clicks the ones they think match. Until now the Passage was read off the page as prose. Attach a Character and it becomes that Character’s speech:

Four things say whose words those are, all at once: the tail pointing at the face, the marquee carrying the name, the italic, and the inked frame. None of them is a sentence explaining it.
The layout came with rules attached
The design for this arrived as a working prototype — spot-sentence-v1-quadrinhos.html, a full HTML page with its own CSS and its own JavaScript. Refinement was conducted against it, and it is a good layout.
It is also a second design of the exercise, and nobody asked for one.
The Card for this item is explicit that attaching a Character is presentation only: same Challenge, same answer key, same Check behaviour. The prototype’s JavaScript disagrees in five places. MAX_ATTEMPTS = 3, with a round that ends and resets. A three-rung feedback ladder that is not the ladder ADR 0025 defines. A Tentativa 1/3 chip in the topbar. Per-Sentence note explanations, and a modal listing them, against an authoring field that does not exist.
And this:
.fragment.revealed, .fragment.revealed:hover {
background: transparent;
box-shadow: inset 0 -2px 0 var(--moss-600);
}
.revealed underlines the correct Sentence the Student did not select, once the round ends.
Four of those five are tuning preferences — reasonable, arguable, someone’s call. That one is not. ADR 0025 holds that unselected Sentences are never marked, and the reason is not squeamishness about spoilers: if wrong picks are marked and unpicked correct ones are marked, the correct set falls out by inverting the feedback. The answer key lives server-side (ADR 0014) precisely so it cannot be reconstructed in the browser, and .revealed reconstructs it in CSS.
So this item shipped the frame and refused the rules. The bubble, the tail, the marker-pen highlighting, the ink-and-offset-shadow buttons, the half-tone paper — all of it. The ladder is still ADR 0025’s: Hint at the third failed Check, a mark on the Student’s own selections from the fourth, nothing ever on a Sentence they left alone.

Attempts stay unlimited, so there is no attempt counter to put in the topbar. A Tentativa 1/3 chip on a screen that will happily accept a twelfth Check is not a design detail; it is a lie about the product.
What would make the next one cheaper
It would be easy to read all that as the prototype was bad. It wasn’t. The trap is structural, and it is worth naming so the next handoff avoids it.
A static mockup can show a layout and stop. A working prototype cannot: to demonstrate an interaction it has to implement some rules, so it inevitably encodes a feedback ladder, an attempt policy, an end state — whether or not anybody asked for them. That is exactly why a working prototype is the more useful artifact, and exactly why it arrives carrying decisions nobody made.
The fix is cheap and lives in the prototype itself — a comment block at the top of the file, before the CSS:
<!-- PROPOSED: the comic frame — bubble, tail, ink outlines, marker-pen marks.
BORROWED to make the page run, not proposed: the 3-attempt ladder and the
round that resets. Real behaviour is ADR 0025.
DEPARTS from ADR 0025 on purpose, and wants an argument: `.revealed`. -->
Three buckets — proposed, borrowed so the page runs, deliberately departing from a shipped decision — and the third naming the ADR it departs from. The first two save the reading; the third is the one that matters, because it turns a silent contradiction into a question somebody has to answer before implementation starts.
Refinement got there anyway, by reading the prototype’s JavaScript against the ADRs line by line. That took a while, and it is the kind of check that works until the once it doesn’t.
Where the attachment lives
A Sentence Selection already stores its whole document — source, Sentences, ids, correct flags — in one jsonb column. The path of least resistance was a "character_id" key inside it.
It is the wrong place, for two reasons.
flowchart TB
subgraph rejected["rejected: a key in the blob"]
direction TB
J["lessons.sentence_selection
jsonb"] -.->|"no constraint,
no integrity"| JC["{ character_id: 47 }"]
JC -.->|"row 47 may not exist"| JX["💥 discovered by a Student"]
end
subgraph shipped["shipped: a shared column"]
direction TB
L["lessons
character_id · nullable FK
on_delete: restrict"]
L -->|"belongs_to"| CH["Character
name · title
neutral · speaking"]
L -.->|"inherited, unused"| WM["Word Match"]
L -.->|"inherited, unused"| GR["Grouping"]
end
style L fill:#f5decc,stroke:#a14a2a
style CH fill:#e4ecda,stroke:#4a6b3e
style JX fill:#fbe6e1,stroke:#b14333
First, referential integrity is real here. A jsonb key naming a row that does not exist is a bug nothing catches until a Student opens the Lesson. A foreign key is checked by Postgres on every write.
Second, and more to the point: CONTEXT.md frames a Character as attached to content, not to one Lesson type. Burying the reference in sentence_selection would make the next type that wants a Character do the whole thing again in its own blob. A shared nullable column follows the precedent the prompt column already set, and the next type inherits it for free.
on_delete: :restrict is inert today — Forgia.Characters exposes no delete — but it is the behaviour to want when one is added. A Lesson pointing at a deleted Character should fail loudly at the delete, not blank itself silently.
The write that wasn’t
The picker’s “No Character” option is how a Character is detached. It submits "".
lesson
|> cast(attrs, [..., :character_id])
That does nothing. Ecto.Changeset.cast/3 treats "" as no value given and skips the field entirely — so the changeset is valid, Repo.update/1 returns {:ok, lesson}, the flash says saved, and the Character is still attached.
This is the third time this codebase has been bitten by a silently-skipped Ecto write, which is why it is now the first entry in the project’s pitfall list. The fix is to make the intent explicit before casting rather than widening empty_values — that would also let a blank title through:
defp normalize_character_id(attrs) when is_map(attrs) do
case attrs do
%{"character_id" => ""} -> %{attrs | "character_id" => nil}
%{character_id: ""} -> %{attrs | character_id: nil}
_ -> attrs
end
end
Worth noting what the test for this has to look like. Asserting {:ok, _} = update(...) passes against the broken version. The assertion has to be on the row:
assert is_nil(detached.character_id)
assert is_nil(Repo.reload(lesson).character_id)
Two skins, one exercise
A Sentence Selection with no Character keeps the skin it has always had, byte for byte:

That is the majority case and it was not allowed to move. But it means the component now renders two quite different pages, and the risk is that they drift — that a change lands on one and not the other, and the comic skin starts handing the browser something the plain skin does not.
So the branch is at the top, and the part that matters is shared:
def render(%{lesson: %{character: %Character{}}} = assigns), do: comic(assigns)
def render(assigns), do: plain(assigns)
Matching %Character{} rather than testing for nil is deliberate: an unpreloaded association is an Ecto.Association.NotLoaded struct, which falls through to the plain skin instead of blowing up mid-render.
Both skins then call one passage/1 component for the Sentences themselves. Whatever the DOM carries for a Sentence — its id, its text, a mark if a Hint has unlocked one — it carries identically in both, because there is only one place that writes it.
Prefixing on the way in
The prototype’s stylesheet declares bare .panel, .bubble, .fragment, .feedback-band, .meta-chip. daisyUI is loaded in this app and ships components under plain names. Copying that markup across is how a Save button once became silently unclickable: daisyUI’s .checkbox hard-sets a square width/height, a <label class="checkbox"> collapsed to 21×21, its text spilled out of its box, and the spilled text ate the clicks.
Every class came across prefixed — ss-panel, ss-bubble, ss-feedback-band. The completion modal likewise got its own ss-comic-modal* rather than restyling the shared .mm-dialog, so Word Match and the Character-less Sentence Selection keep the modal they have:

The Character is deliberately absent from that dialog. The prototype leaves it speaker-less, and it is right to: the moment the Lesson is won is the platform talking, not the cast.
Who owns a class
The speaking flag — three little bars, animating — pauses while a Check verdict is on screen and resumes the moment the Student changes a selection. That is the same decay rule the Hint marks follow: nothing on this screen changes except when a Check is spent.
The prototype does it with body.answered, set once, terminally. Ours is not terminal, and that raises a question this codebase has already answered the hard way: who owns the class?
A previous item’s shuffle animation kept replaying because a hook tried to remove a class the template rendered. morphdom re-applies the attributes it is given, and the template always says the same thing, so every patch put it back. The rule that came out of it: client-owned state must be client-added.
Here the state is server-owned — it is a function of @feedback, which the server already holds — so the server renders it and the server retires it, and no hook is involved at all:
<div class={["ss-comic", @feedback && "ss-answered"]}>
What the tests could not have caught
The suite has 1,555 tests and this item added 39 of them. Four of its acceptance criteria are not among those 39, on purpose — ExUnit cannot honestly carry them, and a green test claiming otherwise is worse than no test:
- the flag’s pause, and its
prefers-reduced-motionsuppression — CSS animation state - the 920px collapse — layout
- the absence of a bare-daisyUI collision — leaves the DOM perfectly well-formed
Those live in the browser pass instead, which asserts on computed style rather than on markup:
PASS speaking flag animates before any Check
PASS speaking flag pauses while a verdict shows (animation-play-state: paused)
PASS speaking flag resumes when a selection changes
PASS prefers-reduced-motion suppresses the animation (animation-name: none)
PASS collapses to one column at 900px (cols: 1)
PASS the tail flips to point up
PASS clickable: fragment[0..2], check-button, modal-control[0..2]
That last line is document.elementFromPoint at each control’s own centre, asked whether the element it hits is the control. It is the check that would have caught the dead Save button, and it is the only kind that can.

And two assertions about the DOM that are really assertions about the product:
PASS no correct flag in the DOM
PASS no .revealed class anywhere
The Instructor’s half
The picker sits between the Prompt and the Passage — the same place the Instructor writes the words the Character will speak.

And the editor’s existing click-to-mark preview renders the attached Character against the very passage it is about to deliver, resolved from the form’s current value rather than from the saved Lesson — so it appears the moment the picker changes, with no save:

One word, nearly wrong
Eight new strings, all with a filled-in pt msgstr — the aliases pin --no-fuzzy, so an empty one ships the English msgid and is invisible to everyone except the people actually reading the site in Portuguese.
Seven were straightforward. The eighth was a chip labelling the verdict band, and reusing the existing gettext("Check") looked obviously right — Check is the domain term for exactly this moment.
In the browser it read CORRIGIR.
That translation is correct, and correct for the button, where Check is a verb and the Student is being asked to do something. On a band reporting what already happened, an imperative reads as an instruction to go fix it. The label got its own msgid — Devolutiva — and the domain term stayed on the button where the action is.
A screenshot caught that. No test would have: the string was translated, non-empty, and rendered exactly where it was told to.