Static-Page Internationalization (i18n) Design
Date: 2026-05-20 Status: Draft — awaiting user review
Goal
Make the public/static surface (landing, pricing, legal, MCP, welcome, lifehub_plan)
bilingual (English + Brazilian Portuguese), driven by the visitor's browser
language by default, overridable via a flag toggle in the navbar, and persisted
client-side. At signup, the visitor's chosen language is copied into
finance_settings["locale"] so the admin/dashboard starts in the same language.
Currency ($ vs R$) follows automatically — that wiring already exists
(User#finance_currency).
Non-goals
- Changing locale behavior for authenticated routes (admin/dashboard) — the
existing
set_localearound-action inApplicationControlleris unchanged. - Translating the 46 internal implementation-note markdown files exposed at
/documentation/:page. Only the two user-facing docs (welcome,lifehub_plan) are migrated. Gating or removing internal docs from public exposure is a separate concern. - Adding any new locales beyond
enandpt-BR.
Architecture overview
Two locale tracks that intentionally do not stay in sync:
-
Public/static track. Driven by
cookies[:locale]↔localStorage["lifehub_locale"]. First visit: the server readsAccept-Language. Subsequent visits: server reads the cookie. JS keeps localStorage and cookie in lockstep. -
Authenticated track. Unchanged.
set_localearound-action keeps readingcurrent_user.finance_locale.
Single sync point — signup. The Devise registration form submits the
visitor's current locale (from localStorage) in a hidden field. The
Users::RegistrationsController saves it to finance_settings["locale"] on the
new user. After that, the two tracks evolve independently.
Currency: unchanged. User#finance_currency already returns "USD" when
locale is en, else "BRL". Confirmed in app/models/user.rb:131.
Components
New files
| Path | Purpose |
|---|---|
app/models/static/locale_resolver.rb |
Namespaced model class. Given a Rails request, returns :en or :'pt-BR'. Priority: cookie → Accept-Language → :'pt-BR'. |
app/controllers/concerns/locale_setter.rb |
Controller concern. around_action :with_static_locale (unconditional — see Data flow / signed-in-user case for why). Resolves locale, runs I18n.with_locale(...), writes resolved value to cookies[:locale] with 1-year expiry, SameSite=Lax. |
app/javascript/controllers/locale_toggle_controller.js |
Stimulus controller. On click: write cookie + localStorage, Turbo.visit(window.location.href). On connect: reconcile cookie ↔ localStorage (localStorage wins if both set and differ). |
app/views/static/_language_toggle.html.erb |
Two flag-emoji buttons (🇺🇸 / 🇧🇷). Active state highlights the current locale. |
app/views/static/welcome.html.erb |
Converted from docs/welcome.md. Uses t() for all copy. |
app/views/static/lifehub_plan.html.erb |
Converted from docs/lifehub_plan.md. Uses t() for all copy. |
config/locales/static.en.yml |
All English static-surface translations. |
config/locales/static.pt-BR.yml |
All Portuguese static-surface translations. Includes content migrated from existing static: and footer: namespaces in pt-BR.yml. |
Modified files
| Path | Change |
|---|---|
app/controllers/static_controller.rb |
include LocaleSetter. Add welcome and lifehub_plan actions (empty; templates render). |
app/controllers/users/registrations_controller.rb |
Override Devise's create. After build, copy params.dig(:user, :signup_locale) into finance_settings["locale"] if present and valid (in %w[en pt-BR]). |
app/views/devise/registrations/new.html.erb |
Add hidden <input name="user[signup_locale]" data-signup-locale-target="input">. Add small Stimulus controller signup-locale that reads localStorage["lifehub_locale"] on connect and fills the input. |
app/views/layouts/static.html.erb |
<html lang="<%= I18n.locale %>">. Replace hardcoded title and description with t("static.meta.default_title") and t("static.meta.default_description"). |
app/views/static/_navbar.html.erb |
Render _language_toggle. Replace all hardcoded labels with t("static.navbar.*"). |
app/views/static/_footer.html.erb |
Replace all hardcoded labels with t("static.footer.*"). |
app/views/static/index.html.erb (1279 lines) |
Every hardcoded string → t("static.landing.<scope>.<key>"). |
app/views/static/pricing.html.erb + partials it renders |
t("static.pricing.*"). |
app/views/static/terms.html.erb, privacy.html.erb, _legal_page.html.erb |
t("static.legal.terms.*"), t("static.legal.privacy.*"). English versions of long-form legal text marked as "draft — counsel review required" in the spec; literal translation provided. |
app/views/static/mcp.html.erb (503 lines), mcp_tools.html.erb (280 lines) |
t("static.mcp.*"), t("static.mcp_tools.*"). Tool identifiers stay in English; only surrounding copy is translated. |
All section partials: _landing_section, _levels_badges_strip, _vision_section_card, _tool_card, all _*_mockup, all _*_preview, _year_heatmap |
Strings → t(). |
config/locales/en.yml, config/locales/pt-BR.yml |
Remove static: and footer: namespaces (migrated into new files). |
config/routes.rb |
Add get "welcome", to: "static#welcome" and get "lifehub_plan", to: "static#lifehub_plan". |
Files NOT changed
app/controllers/application_controller.rbset_localeand thearound_actionpredicate.app/models/documentation/parser.rb— still serves the 46 internal implementation-note markdown files at/documentation/:page.app/models/user.rbfinance_localeandfinance_currencymethods.
Data flow
Visitor — first visit
GET / with Accept-Language: en-US,en;q=0.9
StaticController#index
LocaleSetter#with_static_locale
Static::LocaleResolver.call(request) → :en
(no cookie; header doesn't start with "pt")
I18n.with_locale(:en) { ... render ... }
response.set_cookie(:locale, value: "en",
expires: 1.year.from_now,
same_site: :lax,
secure: Rails.env.production?)
JS connects → reads cookie → mirrors to localStorage["lifehub_locale"] = "en"
Visitor — toggle to pt-BR
Click 🇧🇷 in navbar
locale_toggle_controller#switch
document.cookie = "locale=pt-BR; path=/; max-age=31536000; samesite=lax"
localStorage.setItem("lifehub_locale", "pt-BR")
Turbo.visit(window.location.href)
→ server re-renders in pt-BR (cookie now wins over Accept-Language)
Visitor — signup
GET /users/sign_up
Devise renders form
signup_locale_controller#connect reads localStorage → fills hidden input
POST /users
params[:user][:signup_locale] = "en" (for example)
Users::RegistrationsController#create
super (Devise builds and saves user)
after build: user.finance_settings = (user.finance_settings || {}).merge(
"locale" => "en"
) if valid locale
Devise persists; user.finance_locale == "en" → finance_currency == "USD"
After-sign-in redirect to dashboard:
ApplicationController#set_locale reads current_user.finance_locale → :en
Logged-in user visiting /
GET / (signed in, has finance_settings["locale"] = "pt-BR", cookie locale = "en")
ApplicationController#set_locale runs (user_signed_in?) → wraps with pt-BR
StaticController#with_static_locale runs (unconditional) → wraps with en
action executes → I18n.locale == :en → page in English
end (restores to pt-BR briefly, then to nil)
end
The two I18n.with_locale blocks nest. The inner (static) one wins for the
duration of the action body. This is deterministic Rails callback ordering:
callbacks registered in the parent class run first (outermost wrap); callbacks
registered in the subclass (StaticController include LocaleSetter) run after
(innermost wrap). The action body sees the innermost-set locale. When the
action returns, the inner with_locale's ensure restores to the outer
value, which is itself restored on outer return. No brittleness, but covered
by an integration test (StaticControllerTest "signed-in user sees static in
cookie locale, admin in DB locale").
Translation file structure
Both files have a single top-level locale key, then a static: namespace.
The structure below shows the top-level shape and a representative slice of
keys. The exhaustive key list (every individual landing.<section>.<copy>
entry across all of index.html.erb, partials, and other pages) is enumerated
by the implementation plan, not this design doc. The migration step extracts
strings as views are converted.
# config/locales/static.pt-BR.yml
pt-BR:
static:
meta:
default_title: "Lifehub — Organize sua vida"
default_description: "Controle contas, investimentos, despesas e metas..."
og_description: "..."
twitter_description: "..."
navbar:
brand: "Lifehub"
features: "Recursos"
pricing: "Preços"
mcp: "MCP"
docs: "Documentação"
sign_in: "Entrar"
sign_up: "Cadastrar"
language_toggle:
aria_label: "Selecionar idioma"
en_label: "English"
pt_label: "Português (Brasil)"
footer:
terms: "Termos"
privacy: "Privacidade"
copyright_html: "© %{year} Lifehub. Todos os direitos reservados."
landing:
hero:
headline: "..."
subhead: "..."
cta_primary: "Começar grátis"
cta_secondary: "Ver demonstração"
# Migrated from existing pt-BR.yml `static:` namespace:
year_heatmap:
tag: "Veja seu ano"
headline: "Cada dia conta. Cada hábito ganha cor."
subhead: "..."
vision: { ... }
focus_timer: { ... }
leaderboard: { ... }
gamer_dashboard: { ... }
levels_badges: { ... }
# New (currently hardcoded):
features: { ... }
pricing_teaser: { ... }
faq: { ... }
final_cta: { ... }
pricing:
headline: "..."
tiers: { ... }
faq: { ... }
legal:
terms:
title: "Termos de Uso"
last_updated: "Última atualização: %{date}"
body_html: "..." # NOTE: rich HTML stored as _html-suffixed key
privacy:
title: "Política de Privacidade"
last_updated: "Última atualização: %{date}"
body_html: "..."
mcp:
hero: { ... }
sections: { ... }
setup: { ... }
mcp_tools:
headline: "..."
groups: { ... } # group_name keys translated; tool identifiers stay literal
welcome:
title: "Boas-vindas ao Lifehub"
body_html: "..." # converted from docs/welcome.md
lifehub_plan:
title: "..."
body_html: "..." # converted from docs/lifehub_plan.md
static.en.yml mirrors the same key structure with English values.
Migration of existing keys
The current pt-BR.yml has static: (lines 2206–2246) and footer: (lines
2249+). These are deleted from pt-BR.yml and en.yml and re-located under the
new static.<locale>.yml files at paths static.landing.* (formerly static.landing.*)
and static.footer.* (formerly footer.*). All ERB references in static views
are updated to the new paths. Non-static-view consumers (none in current
codebase, based on grep -rn "I18n.t.*static\|t(.*static" app/) need no
changes — but a grep verification is part of the implementation checklist.
Configuration
No changes to config/application.rb. Existing settings already cover the
case:
config.i18n.default_locale = :'pt-BR'
config.i18n.available_locales = [:'pt-BR', :en]
config/locales/*.yml glob is the default load path, so new files are
auto-discovered.
Testing
Unit
Static::LocaleResolverTest- cookie
pt-BR+ headeren→:'pt-BR'(cookie wins) - no cookie + header
en-US,en;q=0.9→:en - no cookie + header
pt-BR,pt;q=0.9,en;q=0.8→:'pt-BR' - no cookie + header
fr-FR,fr;q=0.9→:'pt-BR'(default) - no cookie + no header →
:'pt-BR' - cookie with garbage value (
"de","xx", empty) → falls through to header
- cookie
Integration
StaticControllerTest- GET
/withAccept-Language: en-US→ response body contains"Get started free"(English landing copy) and<html lang="en">. - GET
/withAccept-Language: pt-BR→ contains"Começar grátis"and<html lang="pt-BR">. - GET
/setscookies[:locale]after resolution. - GET
/with cookie localeen+ headerpt-BR→ English wins. - GET
/welcomeand GET/lifehub_planrender in both locales.
- GET
Users::RegistrationsControllerTest- POST
/userswithuser[signup_locale]=en→ created user'sfinance_settings["locale"] == "en"andfinance_currency == "USD". - POST
/userswithuser[signup_locale]=fr(invalid) → user created with no locale set (falls through to defaultpt-BR). - POST
/userswith nosignup_locale→ no change to defaults.
- POST
- System (Capybara)
- Visit
/, click 🇺🇸 button → page reloads in English. Reload again → still English (cookie persists). - Visit
/as signed-in user withfinance_locale = "pt-BR"and cookielocale = en→ page in English. Navigate to/dashboard→ page in Portuguese (admin track unaffected). Confirms the dual-track design.
- Visit
Translation completeness
A test that loads both static locale files and asserts the key sets are identical (recursive). Prevents one-sided additions during future edits.
# test/i18n/static_translations_test.rb
test "static.en.yml and static.pt-BR.yml have identical key sets" do
en_keys = flatten_keys(YAML.load_file(Rails.root.join("config/locales/static.en.yml")))
pt_keys = flatten_keys(YAML.load_file(Rails.root.join("config/locales/static.pt-BR.yml")))
assert_equal en_keys.sort, pt_keys.sort
end
Hardcoded-string sweep
Implementation checklist includes a final grep pass over the changed view
files for common Portuguese giveaways (ção, çã, áticos, Começar,
Visão, Termos, Privacidade) to catch leftover hardcoded strings. Any
remaining English-only strings (e.g., tool identifiers) are wrapped in a
comment <%# i18n-skip: identifier %>.
Risks and edge cases
- Long-form legal text.
terms.html.erbandprivacy.html.erbcontain substantive legal language. Translating to English is real work and could introduce legal risk if the English version is not equivalent. The English versions delivered by this spec are literal translations of the existing Portuguese, marked as drafts pending counsel review. The English-locale rendering includes a disclaimer banner above the legal content:t("static.legal.english_draft_notice")— "This English translation is provided for convenience; the Portuguese version is authoritative." This banner is hidden whenI18n.locale == :'pt-BR'. - MCP tool descriptions. Tool names (e.g.,
create_expense,list_habits) are stable identifiers and remain unquoted/untranslated. Only the descriptive sentences around them are translated. - Cookie/localStorage divergence. If a user clears cookies but localStorage
survives (or vice versa),
locale_toggle_controller#connectreconciles: whichever is present wins, the other is set to match. If both are present and differ, localStorage wins (treated as user-explicit memory) and the cookie is overwritten to match. - Turbo / morphing.
Turbo.visit(href)is used rather than a hard reload so navigation feels snappy. If Turbo morphs vs. full re-render becomes an issue (e.g., Stimulus controllers stuck in stale state after locale swap), fall back towindow.location.reload(). - SEO duplicate-content. Both locales render at the same URL but with
different content. Add
<link rel="alternate" hreflang="en" href="..."/>and<link rel="alternate" hreflang="pt-BR" href="..."/>to the static layout — with the same canonical URL — so search engines understand language variants. Implementation includes this. - No GET param locale override. The toggle goes via cookie + reload, not
?locale=en. This keeps URLs canonical and avoids cache fragmentation. Documented for future maintainers. - Devise param sanitization.
signup_localemust be added todevise_parameter_sanitizer.permit(:sign_up, keys: [:signup_locale])or the controller override must read it from raw params before delegating. Spec uses the latter for explicitness. - Cookie scope.
cookies[:locale]is set on the static-page response only, but the cookie is sent on all requests. The admin route ignores it (usescurrent_user.finance_locale). No leakage.
Implementation order
Suggested ordering, but the writing-plans skill will refine:
- New files:
Static::LocaleResolver,LocaleSetterconcern,LocaleResolverTest. - Wire
LocaleSetterintoStaticController. Verify with integration test that GET/respectsAccept-Language. No view changes yet — page can be half-Portuguese. - Create
static.pt-BR.ymlandstatic.en.ymlwithmeta,navbar,footernamespaces. Migrate existing keys frompt-BR.yml/en.yml. - Update
layouts/static.html.erb,_navbar.html.erb,_footer.html.erb. - Stimulus
locale_toggle_controller.js+_language_toggle.html.erbpartial. Render in navbar. Manual test: toggle works. - Translate
index.html.erband all section partials. Most lines of work. - Translate
pricing.html.erb. - Translate
terms.html.erb,privacy.html.erb,_legal_page.html.erb. - Translate
mcp.html.erbandmcp_tools.html.erb. - Convert
docs/welcome.mdanddocs/lifehub_plan.mdto ERB views, add routes, add translations. - Devise registration override: hidden field, controller, parameter sanitization, test.
- Translation-completeness test + hardcoded-string sweep.
- Add
hreflangalternate links to static layout. - Manual smoke pass: every static URL in both languages.
Out-of-scope follow-ups
- Gating or removing the public
/documentation/:pageroute that exposes 46 internal implementation-note markdown files. - Adding more locales (Spanish, etc.).
- Translating mailer templates and devise system emails (currently outside static-surface scope).
- Currency formatting helpers —
finance_currencyreturns"USD"/"BRL"but not a symbol. If display code anywhere hardcodesR$, that's a separate cleanup.