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_locale around-action in ApplicationController is 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 en and pt-BR.

Architecture overview

Two locale tracks that intentionally do not stay in sync:

  1. Public/static track. Driven by cookies[:locale]localStorage["lifehub_locale"]. First visit: the server reads Accept-Language. Subsequent visits: server reads the cookie. JS keeps localStorage and cookie in lockstep.

  2. Authenticated track. Unchanged. set_locale around-action keeps reading current_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.rb set_locale and the around_action predicate.
  • app/models/documentation/parser.rb — still serves the 46 internal implementation-note markdown files at /documentation/:page.
  • app/models/user.rb finance_locale and finance_currency methods.

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 + header en:'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

Integration

  • StaticControllerTest
    • GET / with Accept-Language: en-US → response body contains "Get started free" (English landing copy) and <html lang="en">.
    • GET / with Accept-Language: pt-BR → contains "Começar grátis" and <html lang="pt-BR">.
    • GET / sets cookies[:locale] after resolution.
    • GET / with cookie locale en + header pt-BR → English wins.
    • GET /welcome and GET /lifehub_plan render in both locales.
  • Users::RegistrationsControllerTest
    • POST /users with user[signup_locale]=en → created user's finance_settings["locale"] == "en" and finance_currency == "USD".
    • POST /users with user[signup_locale]=fr (invalid) → user created with no locale set (falls through to default pt-BR).
    • POST /users with no signup_locale → no change to defaults.
  • System (Capybara)
    • Visit /, click 🇺🇸 button → page reloads in English. Reload again → still English (cookie persists).
    • Visit / as signed-in user with finance_locale = "pt-BR" and cookie locale = en → page in English. Navigate to /dashboard → page in Portuguese (admin track unaffected). Confirms the dual-track design.

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.erb and privacy.html.erb contain 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 when I18n.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#connect reconciles: 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 to window.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_locale must be added to devise_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 (uses current_user.finance_locale). No leakage.

Implementation order

Suggested ordering, but the writing-plans skill will refine:

  1. New files: Static::LocaleResolver, LocaleSetter concern, LocaleResolverTest.
  2. Wire LocaleSetter into StaticController. Verify with integration test that GET / respects Accept-Language. No view changes yet — page can be half-Portuguese.
  3. Create static.pt-BR.yml and static.en.yml with meta, navbar, footer namespaces. Migrate existing keys from pt-BR.yml/en.yml.
  4. Update layouts/static.html.erb, _navbar.html.erb, _footer.html.erb.
  5. Stimulus locale_toggle_controller.js + _language_toggle.html.erb partial. Render in navbar. Manual test: toggle works.
  6. Translate index.html.erb and all section partials. Most lines of work.
  7. Translate pricing.html.erb.
  8. Translate terms.html.erb, privacy.html.erb, _legal_page.html.erb.
  9. Translate mcp.html.erb and mcp_tools.html.erb.
  10. Convert docs/welcome.md and docs/lifehub_plan.md to ERB views, add routes, add translations.
  11. Devise registration override: hidden field, controller, parameter sanitization, test.
  12. Translation-completeness test + hardcoded-string sweep.
  13. Add hreflang alternate links to static layout.
  14. Manual smoke pass: every static URL in both languages.

Out-of-scope follow-ups

  • Gating or removing the public /documentation/:page route 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_currency returns "USD"/"BRL" but not a symbol. If display code anywhere hardcodes R$, that's a separate cleanup.