The Landing Page item built the actual machinery: a Locale resolved once per request (ForgiaWeb.Plugs.Locale), a Layouts.language_switcher component, and a Profile.preferred_language that follows a person across devices. It wrapped landing_live and catalog_live in gettext/ngettext and called it done — by design, one screen proving the mechanism, not the whole app speaking two languages yet.
This item is the “yet.” Four slices, each its own commit: account settings, the student learning screens (Lesson viewer, course-complete, reviews), the entire Studio (course editor, Quiz/Word Match/Sentence Selection authoring, dashboard, analytics, feedback, certificates), and every transactional email. Plus two small product asks that came in alongside it: a way to jump to “my dashboard” from the header, and the top-left brand becoming an actual link home.
The Locale, end to end
flowchart LR
A[Request] --> B{Plugs.Locale}
B -->|"1. logged-in user's
Profile.preferred_language"| E[Gettext.put_locale]
B -->|"2. locale cookie"| E
B -->|"3. Accept-Language
negotiated"| E
B -->|"4. default (en)"| E
E --> F[Every gettext/ngettext call
in this request renders in that Locale]
That precedence already existed. What was missing was reach: the Studio sidebar, the dashboard header, the certificate page, and four Lesson-type editors had no Layouts.language_switcher anywhere in them — an Instructor working inside a Course had no way to switch languages without leaving the Studio entirely.
The fix was structural rather than repetitive: Layouts.studio_sidebar now takes current_locale and renders the switcher itself, once, so every screen that already includes the shared sidebar picked it up by adding one attribute — not by hand-wiring a switcher into nine separate files.

One screen had drifted from that shared component entirely — the Quiz question editor had its own hand-rolled <aside class="quiz-sidebar">, predating Layouts.studio_sidebar, complete with a hardcoded "Mario Melo" where the actual Instructor’s name belongs. Swapping it for the real component fixed the missing switcher and the wrong name in one pass.
Every count is ngettext, not string patching
"{count} lesson{if count != 1, do: "s"}" — English pluralization is a suffix, so this pattern was everywhere and it was invisible as a bug until Portuguese, where a plural changes the adjective, not just a trailing letter:
# before — only ever correct in English
defp lessons_word(1), do: "lesson"
defp lessons_word(_), do: "lessons"
# after
ngettext(
"A self-paced program of %{count} lesson, completed in full.",
"A self-paced program of %{count} lessons, completed in full.",
certificate.lesson_count
)
The same shape recurred in Word Match’s blank/distractor counters, Sentence Selection’s “N marked correct,” the Analytics attempt counts, and the Students-tab “Send to N person/people.” Every one became a real msgid/msgid_plural pair, so "1 marcada" and "3 marcadas" are two different, correctly-agreeing strings — not one string with a digit swapped in.
The dropdown gets a shortcut home
flowchart TD
U[User menu dropdown] --> D{Is this an Instructor?}
D -->|No| S[Student Dashboard only]
D -->|Yes| SI[Student Dashboard
+ Instructor Dashboard]
Every authenticated header now offers a straight line to “my learning” — and, for an account whose email matches the admin domain, a second line straight to the Studio’s Courses list. It’s the same Forgia.Accounts.User.admin?/1 check the router’s :require_admin plug already uses, just read from the header instead of gating a route:


The Studio sidebar’s own user menu deliberately does not repeat “Instructor Dashboard” — the sidebar’s Courses link already is that shortcut, and a second one right next to it would be noise, not help.
The brand becomes a link
learn.mariomelo.com in the top-left corner was, on most screens, inert text. Layouts.studio_sidebar’s brand_navigate attribute already let a few Studio screens override it to jump back to the current Course; everywhere else it just… sat there. It now defaults to /catalog wherever no more specific target is given — one line changed in the shared component (@brand_navigate || ~p"/catalog"), rather than a per-screen fix repeated nine times.
Emails: the recipient’s Locale, not the sender’s
This is the part with a real trap in it. An email is composed inside someone’s request — an Instructor manually enrolling a Student, a background job, a webhook — and that process’s Gettext locale belongs to whoever (or whatever) triggered it, not to the person about to read the email in their inbox.
sequenceDiagram
participant I as Instructor (Locale: en)
participant N as UserNotifier
participant G as Gettext (process-wide)
participant S as Student (Preferred Language: pt)
I->>N: manual_enroll(course, student, instructor)
N->>G: put_locale("pt") # student's stored preference
N->>N: build subject + HTML + text body
N->>S: send email — renders in pt
N->>G: put_locale("en") # restore — instructor's own flash still renders in en
That restore step on the last line is the trap. Without it, a bulk invite — the Students tab lets an Instructor paste a whole list at once — would leave whatever Locale the last row’s recipient happened to prefer active on the process, and the success flash rendered right after the loop ("5 students enrolled, 2 invited.", meant for the Instructor) would silently come out in the wrong language. with_recipient_locale/2 wraps the whole render-and-send in a try/after specifically so this can’t happen:
defp with_recipient_locale(user, fun) do
previous = Gettext.get_locale(ForgiaWeb.Gettext)
Gettext.put_locale(ForgiaWeb.Gettext, recipient_locale(user))
try do
fun.()
after
Gettext.put_locale(ForgiaWeb.Gettext, previous)
end
end
A person invited by email who doesn’t have an account yet has no Profile to read a preference from at all — that case falls back to the site default rather than guessing.
The manually-enrolled Student’s email had another problem, independent of translation: it had never had an HTML version, ever, in any language — plain-text-only, with no link at all (just “log in to start,” with nothing to click). It now shares the same visual language as the confirmation/magic-link emails already had — table-based layout, the clay CTA button, the account-details card — extended to two new flows that didn’t have a design-doc mockup to copy from:


A bug the fallback convention was hiding
profile_live.ex, course_live/show.ex, form_component.ex, and lesson_live/edit.ex each had their own hand-rolled format_error/1 — a private function that did raw %{key} substitution on a changeset error tuple and never touched Gettext at all. It looked like it worked, because Gettext’s own fallback for a missing translation is the English msgid verbatim — the same output a hardcoded formatter produces. Four independent duplicates of the exact same dead end, found only because wiring every form’s actual translate_error/1 (which does route through the "errors" domain) was the only way to make a required-field error say "não pode ficar em branco" instead of "can't be blank" in Portuguese. All four now use the one real helper; the duplicates are gone.

And on the dashboard itself: "Bem-vindo de volta, {@greeting_name}." was hardcoded — in Portuguese — inside a codebase whose source-of-truth language is English everywhere else. An English-locale Student has been seeing that Portuguese sentence, unconditionally, since before this item started. It’s gettext("Welcome back, %{name}.", name: @greeting_name) now, with the Portuguese moved to where it belongs: errors.po’s sibling, default.po.
Verified
An Instructor and a Student, in separate browser sessions, switching Locale independently: the Student’s choice never affects the Instructor’s session, and vice versa — each is read from that person’s own stored preference, exactly as the flow diagram above says. A blank-name validation error, a pluralized “0 courses in progress,” a bulk enrollment success flash, and both the manual-enrollment and invitation emails were all driven through the real UI (not hand-built LiveView params) and checked in the actual dev mailbox rather than assumed from source.
877 tests pass, up from 869 at the start — the difference is new coverage for the Locale-resolution edge case above, a translated-validation-error assertion, and a pt-BR rendering assertion on the dashboard, on top of the full existing suite staying green throughout (every pre-existing English-literal assertion keeps passing unmodified, since the "en" Locale’s Gettext fallback renders the English msgid verbatim).
What’s deferred
Two follow-ups came out of this refinement, each now its own Card:
- Locale-aware date formatting — five call sites still use
Calendar.strftime(at, "%d %b %Y"), whose%bmonth abbreviation is English-only. Its own item, since it needs either a small locale→month-name map or a dependency decision. - A Course-language filter on the Catalog — this item translates the app’s chrome, never a Course’s authored content; a Student today has no way to filter the Catalog by which language a Course was actually written in. That’s a real product gap, just a different one.