The report was one sentence: on a course page, there is no way back to my courses.

It was accurate and it was worse than it sounded. CatalogLive.Lesson — the Lesson player — has no topnav at all, deliberately: it is a focused screen, and every exit from it lands on the Course overview. That overview is the page a Student always falls back to. And it was the one page in the app that had no link to /dashboard. The only route out was through the public Catalog, which is the shop.

The Course overview topnav, with Catálogo marked active and Meus cursos beside it

The one-line fix, and why it wasn’t taken

show.html.heex was missing four lines that index.html.heex had. Adding them would have closed the ticket in a minute.

Instead the item was widened, on the grounds that the missing link was a symptom of something with a name: the Student topnav was inlined in six templates, and six copies of a nav is six chances for one to go stale. One had. The interesting finding was that it wasn’t the only one — putting the six side by side turned up four independent divergences, and only one of them had ever been reported.

flowchart TD
    subgraph six["Six inlined copies, four disagreements"]
        A["CatalogLive.Index
/catalog"] B["CatalogLive.Show
/catalog/:id"] C["CourseComplete"] D["CertificateLive"] E["DashboardLive"] F["ProfileLive"] end A -->|"hides BOTH links
from a visitor"| D1["drift 1 · gating"] B -->|"no My learning
at all"| D2["drift 2 · the reported bug"] C -->|"no language
switcher"| D3["drift 3 · missing chrome"] A --> D4["drift 4 · divider margin
inline on 4, absent on 2"] C --> D4 E --> D4 F --> D4 D1 --> G["Layouts.student_topnav/1"] D2 --> G D3 --> G D4 --> G style D2 fill:#f0dcdc,stroke:#a06060 style G fill:#dce8dc,stroke:#5f7a5f

The precedent was already in the file. Layouts.studio_sidebar/1 went through exactly this refactor on the Instructor side, and its docstring says why: “it was inlined in all six admin screens until the Feedback entry needed to appear in every one of them.” Same file, same shape, same count. student_topnav is the Student’s half of a pair that already existed.

The divergence that was a bug nobody filed

/catalog — the public catalogue, the page a prospective buyer lands on — wrapped its entire link block in if @current_user. A logged-out visitor saw the brand and nothing else. Show and Certificate, the other two public pages, showed the Catalog link unconditionally.

Two ways to unify that, and they are not symmetric. Hiding the block is defensible if you read “My learning is for logged-in Students” and then reach for the nearest conditional. But the consequence is that the Catalog link disappears for exactly the person who has not bought anything yet — the one visitor for whom a link to the catalogue is the whole point.

CertificateLive had already solved this properly, gating per-link:

<.link :if={@current_user} navigate={~p"/dashboard"} class="mm-nav-link">
  {gettext("My learning")}
</.link>

The component takes that. /catalog now looks different to a logged-out visitor than it did last week, and that is the item working as intended rather than a side effect of it.

The Catalog as an anonymous visitor sees it: Catálogo, the language switcher, Entrar and Cadastrar

The divider was patching over a rule it already had

Four of the six pages spaced the brand divider with style="margin:0 8px". Two didn’t. The temptation is to pick the majority and move on.

But .mm-topnav-left has declared gap: var(--sp-6) since the design system was written. The inline margin was adding space on top of space that was already there — a local patch for a global rule, applied by whoever copied the block next. There is no version of “unify onto the majority” that is correct, because the majority was the bug. The component uses a .mm-topnav-divider class with no margin at all, and the divergence stops being a choice.

The acceptance criteria said this out loud — “no inline style remains on the divider” — which is what forced reading docs/design-system/styles_components.css before writing the markup instead of after.

Two layers of test, and the repo’s first component test

The component is pure given its assigns, so it needs no database and no connection. That makes it the first thing in this repo to be tested with render_component/2:

use ExUnit.Case, async: true
import Phoenix.LiveViewTest

defp topnav(assigns) do
  render_component(&Layouts.student_topnav/1,
    Map.merge(%{current_locale: "en"}, Map.new(assigns)))
end

That covers what the component does. It cannot cover what six templates pass it, and that is the actual risk of a six-file change: one page quietly handing over the wrong assigns, or none. So each page gets its own assertion in a second file — plus one test that is really a grep:

inlined =
  Path.wildcard("lib/forgia_web/live/**/*.{ex,heex}")
  |> Enum.reject(&(Path.basename(&1) == "landing_live.html.heex"))
  |> Enum.filter(&(File.read!(&1) =~ ~s(class="mm-topnav")))

assert inlined == []

A test like that has a failure mode of its own: if the wildcard ever matches nothing, it passes and means nothing. So it guards itself — it asserts the list is large, and asserts that landing_live.html.heex, which legitimately keeps its own marketing nav, does still match. If the grep stops working, the guard fails before the assertion can lie.

Adding the language switcher broke a test, correctly

CourseComplete was the only one of the six without a language switcher, which reads as copy drift rather than a decision about a celebration screen. The component always renders it.

That immediately failed a test that had nothing to do with navigation:

expected selector "form" to return a single element, but got 2

The completion screen’s name prompt was selected as form("form", …), and the language switcher is a <form> too — it posts to /locale. The fix is one selector. The lesson is that form("form", …) was always a bet on the page containing exactly one, and page chrome is precisely the thing that changes that count without touching the feature under test.

The completion screen, now carrying the language switcher it lacked

What the browser added

Forty-four checks across six pages in both states — logged in and anonymous — asserting the link, its target, the active marker, the language switcher, the user menu, and that no divider carries an inline style.

Two things came out of it that mix test had no opinion about.

The first was the seed, not the app: /catalog/:id takes the Course slug, because Phoenix.Param is implemented for Course. A verification script emitting course.id gets Ecto.NoResultsError and looks exactly like a page that lost its nav. CLAUDE.md warns that the dev database is not a fixture; this is the adjacent trap, where the fixture is fine and the URL is wrong.

The second was a non-finding worth writing down. The link’s x-position was 337, 337, 336, 336, 336, 336 across the six pages. That is not the nav disagreeing with itself — it is the scrollbar on the two pages that don’t scroll. A position check that demands exact equality across pages of different lengths will fail forever for a reason that has nothing to do with what it is testing.

The Dashboard, with Meus cursos marked active

What is still inlined

LandingLive keeps its own topnav and always will: it is a marketing nav of anchor links to Methodology, About and Courses, with neither Catalog nor My learning in it. CatalogLive.Lesson and CatalogLive.Bibliography have no topnav at all — both are focused players with their own lesson_chrome/1, and every exit from them returns to the Course overview.

Which is the page this item was about, and the reason the bug mattered more than one missing link usually does.