Forgia’s lessons are written as prose. They read like documentation, and documentation is not how anybody learns what a Product Owner actually does when the Sprint is on fire. The fix is a cast: recurring figures who deliver the material as someone in a situation.
This item builds the cast. It does not yet put anyone in a lesson — that is the next one. What ships here is the half of the story that belongs to the Instructor: I design each one only once.

A Character is four things, all required: a name, a title, and exactly two Expressions — neutral, the still that identifies them, and speaking, usually a small animated GIF.
The screen an Instructor sees exactly once says all of that, because there is nothing else on it worth saying:

Note what the header does not carry there. “New Character” lives in the invitation, not in the page header — the same solid button twice in one viewport is noise. Populated, it is the other way round: the header carries it and the grid asks for nothing.
Two slots, fixed by the platform
The obvious design is an open set: let the Instructor name their own expressions, add happy and sad and confused as they need them. It is the wrong one, and the reason is totality.
If the slots are open, every future consumer has to carry a fallback for “this Character has no speaking image”, and the cast accumulates a naming free-for-all — talk, talking, speak, fala. If the platform names the slots and requires both, then asking a Character for either Expression always returns a real image the Instructor chose. No fallback rule, no nil check, no guessing.
flowchart LR
subgraph plat["the platform, not a Course"]
C["Character
name · unique, case-insensitive
title · a role or accountability"]
end
C --> N["neutral
the still that identifies
NOT NULL"]
C --> S["speaking
usually an animated GIF
NOT NULL"]
N --> B[("Backblaze B2
bytes untouched")]
S --> B
C -.->|"not yet — next item"| L["Lesson"]
style C fill:#f5decc,stroke:#a14a2a
style N fill:#dce8dc,stroke:#5f7a5f
style S fill:#dce8dc,stroke:#5f7a5f
style L fill:#ece6da,stroke:#b8ae9c,stroke-dasharray: 4 3
null: false on both columns is the point. A guarantee that lives only in a changeset is a guarantee a seed script can walk around.
The cost is real and accepted: a Character cannot be banked while its GIF is still being drawn. That is a worse trade for the Instructor and a much better one for everybody downstream.
The screen is two forms pretending to be one
ImageUploadLive renders its own <form>. A form cannot contain another form — the browser closes the outer one early, and every input after it silently stops submitting. This repo has the scar already.
So the editor is a two-column grid whose left column is a <section> and whose right column is the <form>. They are siblings. The uploaders hand their URLs back through hidden inputs that the form renders from the changeset, like any other field.

The seam is invisible to the eye and real in the markup, which is exactly the sort of claim that deserves a check rather than a comment. LiveViewTest cannot make it — the DOM is well-formed either way. The browser can:
const f = document.querySelector('#character-form');
document.querySelector('#character-name').form === f // true
document.querySelector("input[name='character[neutral_url]']").form === f
f.querySelector('form') // null
f.elements.length // 5
HTMLInputElement.form is the browser’s own answer to “which form will submit this?”, resolved after parsing. If a nested form had closed the outer one, those would be null, and nothing else — not the markup, not the tests, not the render — would have said so.
Then the second uploader broke the uploader
ImageUploadLive had been in production on four screens. Putting two of them on one page broke it twice within a minute.
First, the LiveView crashed the moment the first byte moved:
** (ArgumentError) cannot allow_upload on an existing upload with active entries.
The component calls allow_upload/3 inside update/2. update/2 runs again every time the parent hands the component new assigns — and this editor’s slots close over assigns that change during the upload: a progress scrim appears, the Save button goes unavailable. So the first progress tick re-rendered the parent, which re-ran update/2, which called allow_upload/3 on a live entry, which raises.
The four existing screens never saw it because their inner block closes over nothing that moves.
defp allow_image_upload(socket, assigns) do
if Map.has_key?(socket.assigns, :uploads) do
socket
else
allow_upload(socket, socket.assigns.upload_name, ...)
end
end
Second, with that fixed, starting the speaking upload while neutral was still in flight:
** (RuntimeError) existing upload for image already allowed in another component (1)
Both components had hardcoded the upload name :image. LiveView forbids two components allowing the same name simultaneously, and says so clearly — but only when two of them are actually live at once. Each slot now allows its own name, :neutral and :speaking, defaulting to :image so the other four screens are untouched.
Neither bug is exotic. Both are the same shape: a component is only as correct as the most demanding page it has been put on, and four pages had all been asking it the same easy question.
The second one came out of an ExUnit test rather than the browser, which is worth noting given how much of this post is about things tests cannot see. render_upload(input, "fala.gif", 30) leaves an entry genuinely in flight, so a partial upload followed by a second one reproduces it exactly.
Ecto will skip your write and say nothing
cast/3 records a change only when the new value differs from the struct’s current field value. Mutate a struct in memory — to keep a preview in sync, say — then build the changeset from that same struct, and the change becomes invisible. The write is skipped, silently, and {:ok, record} comes back.
So every update is built from the row as reloaded:
def update_character(%Character{id: id}, attrs) do
id
|> get_character!()
|> Character.changeset(attrs)
|> reject_taken_name()
|> Repo.update()
end
The test that protects it stages the trap rather than describing it: take a saved Character, mutate the struct in memory to the value about to be saved, then save that value. Against the mutated struct the write vanishes. Against the reloaded row it lands.
The name is the handle
The next item’s first job is a picker. A dropdown with two Alysson rows is a coin flip the Instructor only detects by previewing the lesson, so names are unique — and case-insensitively, because alysson and Alysson are the same collision to a human.
The guarantee is a functional unique index:
create unique_index(:characters, ["lower(name)"], name: :characters_lower_name_index)
The context checks first, but only so it can say something useful. A raw constraint error tells the Instructor that a database rule was violated; what they need to know is which Character already has that name:

The interpolation rides in the error’s opts, which is what translate_error/1 hands to Gettext — so the domain names the msgid and stays out of translating it:
Changeset.add_error(
changeset,
:name,
dgettext_noop("errors", "A Character named %{name} already exists."),
name: name
)
Trimming happens before both the validation and the comparison, so " " is blank rather than a three-space name, and " ALYSSON " collides instead of slipping past the index as a distinct string.
Nothing is cropped, ever
There is no image processing anywhere in this codebase, and this item deliberately did not add any. Art will arrive at whatever ratio it arrives at.
So every Expression renders inside a fixed 3:4 box with object-fit: contain, against a deliberate paper tone. Mismatched art letterboxes — it never resizes its container and never shifts the layout. You can see it on the cast grid above: Paulo Otávio’s cartoon is 437×480 and sits in bands, while the photographs are 533×800 and nearly fill theirs.
That matters more than it looks. When the next item makes a Character start speaking, the box must not jump.
The GIF arrives intact
The Card recorded a worry: an animated GIF might not survive the upload pipeline. It was unfounded — Forgia.Storage.upload_image/2 streams bytes straight to B2 with no resizing and no re-encoding — but {:ok, url} from an upload proves nothing whatsoever about the file that gets served back.
So the check is on the served object, from outside the browser:
PASS :: the served speaking file is a multi-frame GIF — 11 frames, GIF87a, image/gif
PASS :: the stored GIF is byte-for-byte the uploaded file
served 189094B fa20583e3a52c7cc / local 189094B fa20583e3a52c7cc
PASS :: the GIF is actually animating in the editor — 75796B then 73983B
The last line is the one worth stealing. The image is cross-origin, so reading it back through a canvas taints it and toDataURL throws. Screenshotting the element twice, 900ms apart, and comparing the two buffers goes around the whole problem — if the bytes differ, something moved.
The grid shows stills, and only stills
Eight looping GIFs in a grid is noise. The grid answers “which Character is this”, which is what a still is for. The editor is where the animation belongs, because the editor is where the Instructor is checking whether the GIF actually works.
No delete
Edit already covers the typo case: rename, replace the art. The guard that will actually matter — a Character attached to a Lesson — cannot be written until something creates that reference, and shipping a destructive action whose guard does not exist yet is the thing being avoided.
When it is written it should restrict, not cascade. A Lesson pointing at a deleted Character is a broken screen a Student discovers, rather than an Instructor.
What the tests could not have caught
Three things in this item were invisible to the suite and are only true because a browser said so:
- The uploaders are outside the form. Well-formed DOM either way;
LiveViewTestnever resolves form ownership. - The Save button is clickable.
document.elementFromPointat the button’s own centre returns the button — the check that would have caught the daisyUI collision that once made a Save button silently dead. - The art letterboxes. Nothing in ExUnit computes layout.
And one that no test of any kind catches: a new msgid ships with an empty msgstr as English, passing every test and every English browser check, visible only to the people who actually read the site in Portuguese. The forty-four new strings are filled in, and checked in the running app:

The Instructor’s own words are the exception, and stay untranslated on purpose. Paulo Otávio, o PO has no English, and neither does the joke.