Every Course in Forgia is a locked box. The catalog shows a title, a description, a price and a padlocked curriculum — and then asks for money. A prospective buyer has to take it on faith that the Lessons inside are any good.

Free Preview fixes that: the Instructor marks a Lesson as free, and anyone can read it. No account, no Enrollment, no magic link. Click through from the catalog and start reading.

The free preview Lesson as a logged-out visitor sees it: a “Free preview” badge in the header, the full lesson body, the rest of the curriculum padlocked in the sidebar, and a call to action back to the Course page

That screenshot is the whole feature. It’s also two bugs I didn’t see coming.

Only Text and Video, and the changeset is where that lives

A Free Preview is served outside any Enrollment. That single sentence decides most of the design, because in Forgia a Completion, a Progress update and a QuizAttempt all hang off an Enrollment. They have nowhere to write without one.

So a Quiz cannot be a Free Preview. Neither can a Word Match. Not because we’d rather not — because there is physically no row to put the answer in. Building an in-memory QuizAttempt for previewers would mean a second quiz engine, maintained forever, for a marketing surface.

The rule lives in the changeset, and it does not shout:

defp clear_free_preview_unless_previewable(changeset) do
  if get_field(changeset, :lesson_type) in [:text, :video] do
    changeset
  else
    put_change(changeset, :free_preview, false)
  end
end

An Instructor who turns a Free Preview into a Quiz has said something perfectly coherent. Refusing the save with a validation error would only send them hunting for a checkbox that is no longer on the screen. The flag just quietly goes away.

And because a flag is only as good as the moment you check it, the serve path re-checks the type rather than trusting the column:

def free_preview?(%__MODULE__{free_preview: true, lesson_type: type})
    when type in [:text, :video],
    do: true

def free_preview?(%__MODULE__{}), do: false

Belt and braces. A row written around the changeset — by a migration, by a console, by me at 2am — still cannot leak a Quiz. There’s a test that does exactly that: update_all sets free_preview: true straight onto a Quiz row, and the preview still refuses to serve it.

The route was never allowed to run

Here’s the first thing I got wrong, and it was in the plan.

Refinement recorded that CatalogLive.Lesson “redirects to log-in on mount” and would need to learn to run with current_user = nil. Reasonable. Except that’s not what was happening. The redirect came from the router:

scope "/", ForgiaWeb do
  pipe_through [:browser, :require_authenticated_user]

  live "/catalog/:course_id/lessons/:lesson_id", CatalogLive.Lesson
end

A pipeline runs before mount. It never saw my new code. It couldn’t — it can only answer “is there a session,” and that is precisely the wrong question. The right one is “is this Lesson a Free Preview, or is this visitor enrolled in this Course,” and a pipeline cannot know either, because it doesn’t know which Lesson you asked for.

So the route moved to the public scope, and the LiveView took over the decision:

flowchart TD
    R["GET /catalog/:course/lessons/:id"] --> E{"Enrolled in
this Course?"} E -->|yes| S["The ordinary Lesson screen
progress · Mark as complete · next Lesson"] E -->|no| F{"Is this Lesson
a Free Preview?
Text or Video only"} F -->|yes| P["The preview screen
no progress · curriculum locked · CTA"] F -->|no| L{"Logged in?"} L -->|no| M["Redirect to log-in"] L -->|yes| C["Redirect to the Course page"]

The order matters and is not arbitrary: Enrollment always beats preview. A Student who paid, opening a Lesson that happens to be a Free Preview, gets the full screen — progress bar, “Mark as complete”, next-Lesson navigation. They bought it. They don’t get shown the marketing.

This is now ADR 0016, and the reason it earned one is uncomfortable: putting that route back behind :require_authenticated_user looks like fixing an oversight. It sits there in the public scope, conspicuously unlike its neighbours. And if you “fix” it, every Free Preview silently dies — and not one test fails. The suite would still be green, because the preview cases would redirect to log-in exactly as the non-preview cases assert they should.

The bug the tests couldn’t see

Now the good one.

The Instructor needs a checkbox. The design system has one — docs/design-system/ defines .checkbox, clay-600 when checked. I copied the markup, ported the CSS, dropped it in the Lesson editor next to the Save button. Wrote the tests. All 684 passed.

Then I opened a browser, and the Save button had stopped working. Not visibly broken. Not erroring. Clicking it just… did nothing. No phx-submit event ever reached the server.

The DOM was immaculate. The form had phx-submit="save". The button was type="submit" and button.form correctly reported lesson-form. The form was valid. Playwright clicked it without complaint. And nothing happened.

The button’s own click listener never fired. So I asked the browser what was actually at the button:

document.elementFromPoint(cx, cy)  // → LABEL.checkbox

Not the button. The label.

Forgia loads daisyUI, and daisyUI ships a .checkbox component of its own. It hard-sets width and height to a fixed square. My <label class="checkbox"> inherited that and collapsed to 21×21 pixels — and here is the part that bites: a shrunken flex item does not clip its text, it overflows it. The words “Free preview” spilled straight out of that 21-pixel box and landed on top of the Save button, an invisible sheet of text swallowing every click.

My own CSS loaded after daisyUI’s. It didn’t save me, because a later rule only wins for the properties it actually declares — and I never declared width. daisyUI’s square survived, underneath my colours.

The fix is a rename, to the mm- prefix every other component in this codebase already uses:

.mm-checkbox { display: flex; align-items: center; gap: 10px; }
.mm-checkbox input:checked { background: var(--clay-600); }

The Lesson editor with the Free preview checkbox on its own row under the title, checked in clay-600, with a hint reading “Anyone can read this Lesson without buying the Course.”

Why no test caught it

This is the lesson worth keeping, and it generalises past CSS.

LiveViewTest doesn’t have a viewport. It doesn’t lay anything out, and it never hit-tests. render_submit(form) finds the form in the DOM tree and submits it — which it did, correctly, every time. The markup was never the problem. The geometry was. There is no assertion I could have added to that test file that would have found this, because the entire failure lives in a dimension the test suite cannot see.

It’s the same shape as the “a form cannot contain another form” trap that bit this project once before: perfectly reasonable code, structurally invisible defect, a green suite, and a screen that quietly doesn’t work. Both are now written down in CLAUDE.md, because the only thing that catches them is opening the browser — which is why the Definition of Done makes that a gate and not a suggestion.

The rest of it

The Instructor gets a second surface — a toggle straight on the Lesson rows of the Course page, so marking a preview doesn’t mean opening the editor. Checked ones stay visible at rest; unchecked ones appear on hover, like the other row actions:

The Instructor’s curriculum with a “Free preview” checkbox on each Text and Video Lesson row, checked on two of them, and no checkbox at all on the Quiz row

The Quiz row has no toggle. Not disabled — absent.

On the buyer’s side, the padlock on a Free Preview’s curriculum row is replaced by a badge, and the row becomes a real link:

The Course curriculum showing two rows badged FREE PREVIEW and clickable, and three rows padlocked

And the catalog card advertises it, so the preview is discoverable before you’ve committed to even opening the Course:

Two catalog cards side by side: one badged FREE PREVIEW, one without, otherwise identical

That badge is one grouped query for the whole grid — the same shape as the Rating aggregate — because a badge that costs a query per card is how a catalog page dies.

What’s deliberately missing

No purchase surface on the preview screen. No Paddle overlay, no price block, no post-payment polling. The call to action does exactly one thing: it navigates to the Course page, which already owns all of that. One purchase flow, one place to break.

And there’s no cap on how many Lessons an Instructor may give away. Giving away the whole Course is a business mistake, not a system error, and the Instructor owns the content. The software doesn’t need an opinion.

The videos raise a flag for later, though: a Free Preview’s video is played by an anonymous viewer with no session. Whatever token scheme lands with Bunny token auth has to issue playback tokens for a viewer who doesn’t exist. Better to know that now than to discover it when the tokens go on.