A Text Lesson was a column of prose. The Instructor wanted three things inside it: a checklist to run before moving on, an example whose key passages explain themselves when you point at them, and flashcards to test a concept on the spot.
All three are written as ordinary markdown, as fenced code blocks whose language is an English keyword. A Lesson body is still just markdown, and any other renderer shows a code block:
```checklist "Before approving the Definition of Done"
Every item can be verified by someone **outside** the team?
[x] The list fits on one screen
```
```annotated "Example · a product team's DoD"
An item is done when [[the code is on main]]::merged, not just pushed.
```
```flashcard "Who writes the Definition of Done?" cover=https://…
The team, and only the team.
```
The three Rich Blocks
A Checklist has a box per line and grades nothing: the point is the habit of re-reading before deciding, not getting it right. [x] pre-ticks a Criterion.

An Annotated Example marks passages in amber. Hovering, tapping or focusing one opens its note. Only one note is open on the page at a time, and Escape closes it.

A Flashcard shows the question on the front and the answer on the back. It flips on click, Enter or Space, with aria-expanded saying which face is up. Two or more in a row share a two-column grid. The optional Cover is decorative, and when the CSP blocks its host, the striped placeholder shows instead of a broken image:

Rendered on the server, escaped all the way
The design-system prototype parsed everything in the browser with marked. Here the one shared Earmark pipeline does it on the server. Earmark, however, passes raw HTML straight through; stopping that is a separate backlog item, Sanitize Lesson Markdown HTML. A Rich Block must not open a second door before that item lands.
So Earmark never sees a Rich Block at all:
flowchart LR
MD["Lesson body
(markdown)"] --> SEG["segment lines
lift out Rich Block fences"]
SEG -->|"the rest + placeholders"| EM["Earmark"]
SEG -->|"each fence"| RB["render the block
escape every piece of text
then ** * ` on escaped text"]
EM --> SW["swap each placeholder
paragraph for its block"]
RB --> SW
SW --> HTML["body HTML"]
SEG -.->|"malformed: no question,
no Criteria, bad fence line"| EM
Every piece of Instructor text inside a block (title, Criterion, note, question, answer, Cover URL) is HTML-escaped first. Only then are **bold**, *em* and `code` applied, by a few lines of our own rather than by Earmark. A <script> typed into a note renders as the text <script>. The same split gives the sanitizer item its seam: sanitize Earmark’s output, then swap the blocks in, and our markup never has to survive the sanitizer.
A malformed block degrades instead of failing. A Flashcard with no question, a Checklist with no Criteria, or a fence line that does not parse goes back into the markdown untouched, and Earmark renders it as the code block it always was. Saving a Lesson never fails because of one.
Prompts do not get any of this. A Flashcard inside a Word Match Prompt would be noise, so Prompts keep the plain Markdown.render/1, and Lesson bodies go through Markdown.body/2. The Lesson editor’s live preview and the Course page each had a private copy of the Earmark call. Both now use the shared pipeline, so the Instructor previews exactly what Students will read, with working blocks.

The three insert buttons put a filled-in template at the cursor through the existing CursorInsert hook. They are type="button" and sit outside the form, so inserting a block never saves the Lesson.
Nothing is stored
Ticks and flips belong to the page. Reloading resets them, and none of them is a Completion, a Challenge Attempt or Progress. A Text Lesson shows a count beside its Chapter progress, as information only:

“Nothing stored” still has to survive LiveView. Clicking “Mark as complete” re-renders the Lesson, and a patch would restore every box and card to what the server last rendered. The Student’s body and the count are therefore phx-update="ignore", with ids keyed on the Lesson, so moving to another Lesson starts fresh. The browser run checks that the ticks, the flips and the count are all still there after “Mark as complete”, and gone after a reload.
What the next item inherits
Sanitize Lesson Markdown HTML was blocked by this one, because the new markup had to survive sanitization. The design above means it no longer has to: the sanitizer runs before any Rich Block markup exists. That item is still a raw Card and stays untouched until it reaches the top of the backlog. The seam is recorded in this item’s Conversation, so whoever refines that one can start from the answer instead of rediscovering the question.