Static-Page Internationalization Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the public/static surface (landing, pricing, legal, MCP, welcome, lifehub_plan) bilingual (English + Brazilian Portuguese), driven by browser language with a navbar flag toggle, persisted in cookie + localStorage, and carried into finance_settings["locale"] at signup.

Architecture: Two locale tracks. Public/static reads cookies[:locale]Accept-Language → default (pt-BR); admin keeps its existing current_user.finance_locale track. A Static::LocaleResolver + LocaleSetter concern handles static resolution and writes the cookie. A Stimulus controller (locale_toggle) writes cookie + localStorage and reloads. The Devise signup form carries the localStorage value into finance_settings["locale"] once, then the two tracks diverge.

Tech Stack: Rails 8, Minitest, Devise, Stimulus, Turbo, Tailwind. Existing User#finance_locale / finance_currency (app/models/user.rb:127–133) already do the $/R$ switch — no model changes needed.

Spec: docs/superpowers/specs/2026-05-20-static-i18n-design.md


How to translate a view (workflow used by Tasks 9–18)

Each view-translation task follows the same loop. Per file:

  1. Open the ERB file.
  2. Identify every user-visible Portuguese string. Heuristic: visible text inside tags, placeholder=, aria-label=, alt= on <img> (unless decorative), title=, and bare strings passed to helpers like link_to "Text", .... Skip:
    • Anchor IDs / href="#fragment" values
    • CSS classes
    • SVG <path> data
    • Stable identifiers (e.g., MCP tool names like create_expense)
    • Mockup demo data deemed "branding screenshot" — described per task where ambiguous
  3. For each string, pick a translation key under static.<page>.<section>.<key> (e.g., static.landing.hero.headline). Use snake_case lowercase.
  4. Replace the string with <%= t("static.<page>.<section>.<key>") %>. For text that contains existing Portuguese-only fragments inside complex markup, isolate the text node before substituting. For HTML-bearing strings (e.g., &copy; ...) use the _html suffix and <%= t("..._html") %>.
  5. Add the key to both config/locales/static.pt-BR.yml (PT value) and config/locales/static.en.yml (EN value) in sorted-ish order under the appropriate sub-namespace.
  6. After finishing the file, run:
    bin/rails test test/i18n/static_translations_test.rb
    bin/rails test test/controllers/static_controller_test.rb
    

    Both must pass before commit.

  7. Visual smoke: bin/rails server and load the URL with Accept-Language: en-US,en;q=0.9 and Accept-Language: pt-BR. Confirm no leftover Portuguese in English, no missing-translation placeholders (translation missing: ...).
  8. Commit.

Cap each view-translation commit at one file (or a small partial cluster). Smaller commits make review and rollback easier when translations need wording revisions.


Task 1: Baseline — branch and snapshot tests

Files:

  • None modified.

  • Step 1: Create branch and run the existing test suite

git checkout -b static-i18n
bin/rails test 2>&1 | tail -30
bin/rails test:system 2>&1 | tail -20

Expected: existing suites green (or document which were already red — only red tests pre-existing in main are tolerated as baseline). If new red tests appear later, they came from this branch.

  • Step 2: Verify static surface currently renders pt-BR
bin/rails server -p 3000 &
SERVER_PID=$!
sleep 3
curl -s -H "Accept-Language: en-US,en;q=0.9" http://localhost:3000/ | grep -c "Começar grátis"
kill $SERVER_PID

Expected: a positive count (page renders Portuguese regardless of header — confirms the bug we're fixing).

  • Step 3: Commit empty marker
git commit --allow-empty -m "chore: branch static-i18n baseline"

Task 2: Static::LocaleResolver model + unit tests

Files:

  • Create: app/models/static/locale_resolver.rb
  • Create: test/models/static/locale_resolver_test.rb

  • Step 1: Write the failing test

test/models/static/locale_resolver_test.rb:

require "test_helper"

class Static::LocaleResolverTest < ActiveSupport::TestCase
  def resolve(cookie: nil, accept_language: nil)
    request = ActionDispatch::Request.new(
      "HTTP_ACCEPT_LANGUAGE" => accept_language,
      "rack.request.cookie_hash" => cookie ? { "locale" => cookie } : {}
    )
    Static::LocaleResolver.call(request)
  end

  test "no cookie, no header → default pt-BR" do
    assert_equal :"pt-BR", resolve
  end

  test "no cookie, header starts with pt → pt-BR" do
    assert_equal :"pt-BR", resolve(accept_language: "pt-BR,pt;q=0.9,en;q=0.8")
    assert_equal :"pt-BR", resolve(accept_language: "pt")
    assert_equal :"pt-BR", resolve(accept_language: "pt-PT")
  end

  test "no cookie, header starts with non-pt → en" do
    assert_equal :en, resolve(accept_language: "en-US,en;q=0.9")
    assert_equal :en, resolve(accept_language: "en")
    assert_equal :en, resolve(accept_language: "fr-FR,fr;q=0.9,en;q=0.8")
    assert_equal :en, resolve(accept_language: "de")
  end

  test "cookie wins over header" do
    assert_equal :"pt-BR", resolve(cookie: "pt-BR", accept_language: "en-US")
    assert_equal :en,      resolve(cookie: "en",    accept_language: "pt-BR")
  end

  test "cookie with invalid value falls through to header" do
    assert_equal :en,      resolve(cookie: "xx", accept_language: "en-US")
    assert_equal :"pt-BR", resolve(cookie: "",   accept_language: "pt-BR")
    assert_equal :"pt-BR", resolve(cookie: "de", accept_language: nil)  # falls through to default
  end
end
  • Step 2: Run test to verify it fails
bin/rails test test/models/static/locale_resolver_test.rb -v

Expected: FAIL with NameError: uninitialized constant Static::LocaleResolver or similar.

  • Step 3: Write the implementation

app/models/static/locale_resolver.rb:

# frozen_string_literal: true

# Resolves the static-surface locale for a request.
#
# Priority: cookie → Accept-Language → default (pt-BR).
# Returns one of :en or :"pt-BR" (the values declared in
# config.i18n.available_locales).
class Static::LocaleResolver
  SUPPORTED = %i[en pt-BR].freeze
  DEFAULT   = :"pt-BR"

  def self.call(request)
    new(request).resolve
  end

  def initialize(request)
    @request = request
  end

  def resolve
    from_cookie || from_header || DEFAULT
  end

  private

  def from_cookie
    value = @request.cookie_jar["locale"] if @request.respond_to?(:cookie_jar)
    value ||= @request.cookies["locale"] if @request.respond_to?(:cookies)
    coerce(value)
  end

  def from_header
    header = @request.env["HTTP_ACCEPT_LANGUAGE"]
    return nil if header.blank?

    primary = header.split(",").first.to_s.strip.downcase
    return :"pt-BR" if primary.start_with?("pt")
    :en
  end

  def coerce(value)
    return nil if value.blank?
    sym = value.to_s.to_sym
    SUPPORTED.include?(sym) ? sym : nil
  end
end
  • Step 4: Run test to verify it passes
bin/rails test test/models/static/locale_resolver_test.rb -v

Expected: all 5 tests pass.

  • Step 5: Commit
git add app/models/static/locale_resolver.rb test/models/static/locale_resolver_test.rb
git commit -m "Add Static::LocaleResolver for cookie/Accept-Language resolution"

Task 3: LocaleSetter controller concern

Files:

  • Create: app/controllers/concerns/locale_setter.rb
  • Create: test/controllers/concerns/locale_setter_test.rb

  • Step 1: Write the failing test

test/controllers/concerns/locale_setter_test.rb:

require "test_helper"

class LocaleSetterTest < ActionDispatch::IntegrationTest
  # Uses StaticController#index as the integration target since the concern is wired there in Task 4.
  # This test is created here but won't pass until Task 4 wires it in.
  test "GET / with Accept-Language en-US renders English and sets cookie" do
    get root_url, headers: { "Accept-Language" => "en-US,en;q=0.9" }
    assert_response :success
    assert_equal "en", cookies["locale"]
  end

  test "GET / with Accept-Language pt-BR sets pt-BR cookie" do
    get root_url, headers: { "Accept-Language" => "pt-BR,pt;q=0.9" }
    assert_response :success
    assert_equal "pt-BR", cookies["locale"]
  end

  test "cookie overrides Accept-Language" do
    cookies["locale"] = "en"
    get root_url, headers: { "Accept-Language" => "pt-BR" }
    assert_response :success
    # I18n locale was :en during render — we verify via the <html lang="en"> attribute set in Task 8.
    # Until Task 8 lands, this assertion is on the cookie persistence only.
    assert_equal "en", cookies["locale"]
  end
end
  • Step 2: Run test to verify it fails
bin/rails test test/controllers/concerns/locale_setter_test.rb -v

Expected: assertion failure on cookies["locale"] being nil (concern not yet wired).

  • Step 3: Write the concern

app/controllers/concerns/locale_setter.rb:

# frozen_string_literal: true

# Resolves and applies the static-surface locale around the action body.
#
# The cookie value is overwritten on every request to keep client storage in
# sync with whatever the server resolved (e.g., a first-visit Accept-Language
# read becomes a sticky cookie). The cookie is 1-year, Lax, secure in prod.
module LocaleSetter
  extend ActiveSupport::Concern

  COOKIE_NAME    = :locale
  COOKIE_EXPIRES = 1.year

  included do
    around_action :with_static_locale
  end

  private

  def with_static_locale
    locale = Static::LocaleResolver.call(request)
    I18n.with_locale(locale) do
      yield
    ensure
      cookies[COOKIE_NAME] = {
        value:     locale.to_s,
        expires:   COOKIE_EXPIRES.from_now,
        same_site: :lax,
        secure:    Rails.env.production?,
        path:      "/"
      }
    end
  end
end
  • Step 4: Don't run the integration tests yet

This concern isn't wired into any controller. Task 4 does that. Move on.

  • Step 5: Commit
git add app/controllers/concerns/locale_setter.rb test/controllers/concerns/locale_setter_test.rb
git commit -m "Add LocaleSetter concern (not yet wired)"

Task 4: Wire LocaleSetter into StaticController

Files:

  • Modify: app/controllers/static_controller.rb

  • Step 1: Run the failing tests written in Task 3

bin/rails test test/controllers/concerns/locale_setter_test.rb -v

Expected: still failing — cookie not set.

  • Step 2: Include the concern

In app/controllers/static_controller.rb, add include LocaleSetter right after skip_before_action :authenticate_user!:

class StaticController < ApplicationController
  skip_before_action :authenticate_user!
  include LocaleSetter

  layout "static"
  # ... rest unchanged ...
end
  • Step 3: Run the tests
bin/rails test test/controllers/concerns/locale_setter_test.rb -v
bin/rails test test/controllers/static_controller_test.rb -v

Expected: all pass (concern tests now pass; pre-existing static tests still pass since they don't depend on locale-set behavior).

  • Step 4: Add integration coverage for the dual-track design

Add to test/controllers/static_controller_test.rb:

test "signed-in user sees static in cookie locale, admin in DB locale" do
  user = users(:admin)
  user.update!(finance_settings: (user.finance_settings || {}).merge("locale" => "pt-BR"))
  sign_in user

  cookies["locale"] = "en"
  get root_url
  assert_response :success
  assert_equal "en", cookies["locale"]
  # Static surface uses cookie (en) regardless of DB pt-BR.
  # Locale-equality during render is asserted via <html lang> after Task 8.

  get dashboard_url
  # Admin uses DB locale (pt-BR). We just check the dashboard renders without 500.
  assert_response :success
end
  • Step 5: Run the new test
bin/rails test test/controllers/static_controller_test.rb -v

Expected: pass.

  • Step 6: Commit
git add app/controllers/static_controller.rb test/controllers/static_controller_test.rb
git commit -m "Wire LocaleSetter into StaticController"

Task 5: Create empty locale files and completeness test

Files:

  • Create: config/locales/static.en.yml
  • Create: config/locales/static.pt-BR.yml
  • Create: test/i18n/static_translations_test.rb

  • Step 1: Write the failing completeness test

test/i18n/static_translations_test.rb:

require "test_helper"
require "yaml"

class StaticTranslationsTest < ActiveSupport::TestCase
  EN = Rails.root.join("config/locales/static.en.yml")
  PT = Rails.root.join("config/locales/static.pt-BR.yml")

  test "static.en.yml and static.pt-BR.yml exist" do
    assert EN.exist?, "missing #{EN}"
    assert PT.exist?, "missing #{PT}"
  end

  test "static.en.yml and static.pt-BR.yml have identical key sets" do
    en_keys = flatten_keys(YAML.load_file(EN).fetch("en"))
    pt_keys = flatten_keys(YAML.load_file(PT).fetch("pt-BR"))
    extra_in_en = en_keys - pt_keys
    extra_in_pt = pt_keys - en_keys
    assert_empty extra_in_en, "Keys present in en but not in pt-BR: #{extra_in_en}"
    assert_empty extra_in_pt, "Keys present in pt-BR but not in en: #{extra_in_pt}"
  end

  private

  def flatten_keys(hash, prefix = "")
    hash.flat_map do |k, v|
      path = prefix.empty? ? k.to_s : "#{prefix}.#{k}"
      v.is_a?(Hash) ? flatten_keys(v, path) : [path]
    end
  end
end
  • Step 2: Run test to verify it fails
bin/rails test test/i18n/static_translations_test.rb -v

Expected: FAIL with "missing config/locales/static.en.yml".

  • Step 3: Create minimal locale files

config/locales/static.en.yml:

en:
  static:
    meta:
      default_title: "Lifehub — Organize your life"
      default_description: "Manage accounts, investments, expenses, and goals. Master your family's financial future."
      og_description: "Your complete platform for personal and family finances."
      twitter_description: "Organize your personal and family finances."

config/locales/static.pt-BR.yml:

pt-BR:
  static:
    meta:
      default_title: "Lifehub — Organize sua vida"
      default_description: "Controle contas, investimentos, despesas e metas. Domine o futuro financeiro da sua família."
      og_description: "Sua plataforma completa de finanças pessoais e familiares."
      twitter_description: "Organize suas finanças pessoais e familiares."
  • Step 4: Run test to verify it passes
bin/rails test test/i18n/static_translations_test.rb -v

Expected: both tests pass.

  • Step 5: Commit
git add config/locales/static.en.yml config/locales/static.pt-BR.yml test/i18n/static_translations_test.rb
git commit -m "Add static locale files with meta keys and completeness test"

Files:

  • Modify: config/locales/en.yml (remove static: block at lines ~2033–2039, footer: block at lines ~2137–2140)
  • Modify: config/locales/pt-BR.yml (remove static: block at lines ~2206–2246, footer: block at lines ~2249–2252)
  • Modify: config/locales/static.en.yml (add migrated keys)
  • Modify: config/locales/static.pt-BR.yml (add migrated keys)
  • Modify: app/views/static/terms.html.erb, privacy.html.erb, pricing.html.erb if they reference the old static.*.title keys
  • Modify: any other file referencing t("footer.*") or top-level t("static.*") (not t("static.landing.*"), which already lives under static:)

  • Step 1: Find all references
grep -rn "t(['\"]\\(static\\|footer\\)\\." /code/life-management/app/ /code/life-management/config/ 2>/dev/null
grep -rn "I18n\.t.*\\(static\\|footer\\)" /code/life-management/app/ /code/life-management/lib/ 2>/dev/null

Record every match (path:line:expression). These all need their reference updated if the key location changes.

  • Step 2: Append migrated keys to the new files

config/locales/static.pt-BR.yml — under the static: root, add:

    # ── Footer (migrated from pt-BR.yml `footer:`) ─────────────────────────────
    footer:
      terms: "Termos"
      privacy: "Privacidade"
      all_rights_reserved: "Todos os direitos reservados."
      copyright_html: "&copy; %{year} Lifehub. %{rights}"
      tagline: "Sua plataforma completa para organizar finanças, hábitos e projetos da sua família."
      sections:
        product: "Produto"
        legal: "Legal"

    # ── Page titles (migrated from pt-BR.yml `static:` block) ──────────────────
    pricing:
      title: "Preços"
    terms:
      title: "Termos"
    privacy:
      title: "Privacidade"

    # ── Landing sections (migrated from pt-BR.yml `static.landing:` block) ─────
    landing:
      cta:
        start_free: "Começar grátis"
      year_heatmap:
        tag: "Veja seu ano"
        headline: "Cada dia conta. Cada hábito ganha cor."
        subhead: "Acompanhe sua consistência ao longo de 365 dias e veja onde sua vida está acendendo."
      vision:
        tag: "Visão"
        headline: "Defina sua estrela-norte — a vida que você está construindo."
        subhead: "Carta dos 100, Lista dos Sonhos, Sua Missão, Definição de Sucesso, Calendário Futuro e Três Caminhos — sua visão é sempre privada e salva automaticamente."
        bullets:
          letter_from_100: "Carta dos 100"
          bucket_list: "Lista dos Sonhos"
          mission: "Sua Missão"
          definition_of_success: "Definição de Sucesso"
          odyssey_plan: "Três Caminhos"
          future_calendar: "Calendário Futuro"
      focus_timer:
        tag: "Foco"
        headline: "Pomodoro com propósito."
        subhead: "Sessões ligadas a tarefas, hábitos ou metas. Streak diário, conclusão, padrões por hora e melhor dia da semana."
        highlights:
          presets: "Presets de duração com sessão ligada a tarefa, hábito ou meta"
          heatmap: "Heatmap dos últimos 6 meses"
          distribution: "Padrões por hora, melhor dia e taxa de conclusão"
      leaderboard:
        tag: "Comunidade"
        headline: "Você não está sozinho. Veja onde você está."
        subhead: "Ranking do mês, hall da fama mensal, feed de atividade da comunidade e sua posição com delta de subida ou descida — tudo em quatro abas."
        cta: "Veja o leaderboard ao vivo"
      gamer_dashboard:
        tag: "Painel Gamer"
        headline: "Sua vida com cara de RPG."
        subhead: "Avatar personalizável, mapa do mundo Lifehub, hábitos do dia, cofre financeiro e conquistas — tudo no painel gamer."
      levels_badges:
        tag: "Conquistas"
        headline: "50 níveis. Avatar personalizável. 46 conquistas."
        subhead: "Suba de Padawan a Transcendent enquanto vive. Cada hábito mantido, cada tarefa concluída, cada sessão de foco — tudo ganha XP."

config/locales/static.en.yml — under the static: root, add the mirrored English keys:

    # ── Footer ──────────────────────────────────────────────────────────────────
    footer:
      terms: "Terms"
      privacy: "Privacy"
      all_rights_reserved: "All rights reserved."
      copyright_html: "&copy; %{year} Lifehub. %{rights}"
      tagline: "Your complete platform to organize your family's finances, habits, and projects."
      sections:
        product: "Product"
        legal: "Legal"

    # ── Page titles ────────────────────────────────────────────────────────────
    pricing:
      title: "Pricing"
    terms:
      title: "Terms"
    privacy:
      title: "Privacy"

    # ── Landing sections ───────────────────────────────────────────────────────
    landing:
      cta:
        start_free: "Get started free"
      year_heatmap:
        tag: "See your year"
        headline: "Every day counts. Every habit lights up."
        subhead: "Track your consistency across 365 days and see where your life is glowing."
      vision:
        tag: "Vision"
        headline: "Define your north star — the life you're building."
        subhead: "Letter from 100, Bucket List, Your Mission, Definition of Success, Future Calendar, and Three Paths — your vision is always private and saved automatically."
        bullets:
          letter_from_100: "Letter from 100"
          bucket_list: "Bucket List"
          mission: "Your Mission"
          definition_of_success: "Definition of Success"
          odyssey_plan: "Three Paths"
          future_calendar: "Future Calendar"
      focus_timer:
        tag: "Focus"
        headline: "Pomodoro with purpose."
        subhead: "Sessions tied to tasks, habits, or goals. Daily streak, completion, hourly patterns, and best day of the week."
        highlights:
          presets: "Duration presets with sessions tied to a task, habit, or goal"
          heatmap: "Last 6-month heatmap"
          distribution: "Hourly patterns, best day, and completion rate"
      leaderboard:
        tag: "Community"
        headline: "You're not alone. See where you stand."
        subhead: "Monthly ranking, monthly hall of fame, community activity feed, and your position with rise/fall delta — all in four tabs."
        cta: "See the live leaderboard"
      gamer_dashboard:
        tag: "Gamer Panel"
        headline: "Your life with an RPG vibe."
        subhead: "Customizable avatar, Lifehub world map, today's habits, financial vault, and achievements — all in the gamer panel."
      levels_badges:
        tag: "Achievements"
        headline: "50 levels. Customizable avatar. 46 achievements."
        subhead: "Climb from Padawan to Transcendent as you live. Every habit kept, every task done, every focus session — all earn XP."
  • Step 3: Remove old blocks from monolithic locale files

From config/locales/pt-BR.yml, delete:

  • Lines containing the # ── Static landing page comment through the end of levels_badges.subhead value.
  • Lines containing the # ── Footer comment through the end of all_rights_reserved.

From config/locales/en.yml, delete:

  • Lines containing the # ── Static comment through the end of static.terms.title value.
  • Lines containing the # ── Footer comment through the end of all_rights_reserved.

Use git diff config/locales/ to verify only the intended blocks are gone.

  • Step 4: Update any references found in Step 1

For each (path:line:expression) recorded:

  • t("footer.terms")t("static.footer.terms") (same pattern for privacy, all_rights_reserved).
  • t("static.pricing.title") → unchanged (path is identical under the new file).
  • t("static.terms.title") → unchanged.
  • t("static.landing.<...>") → unchanged.

If grep returns nothing for footer. references in app code, no edits needed.

  • Step 5: Run the completeness test
bin/rails test test/i18n/static_translations_test.rb -v

Expected: pass (both files have identical key sets).

  • Step 6: Run the full controller suite to catch broken references
bin/rails test test/controllers/static_controller_test.rb -v
bin/rails test 2>&1 | tail -20

Expected: no I18n translation-missing warnings, no new failures.

  • Step 7: Commit
git add config/locales/static.en.yml config/locales/static.pt-BR.yml config/locales/en.yml config/locales/pt-BR.yml
git commit -m "Migrate static: and footer: namespaces to static.{en,pt-BR}.yml"

Task 7: Locale toggle Stimulus controller + partial

Files:

  • Create: app/javascript/controllers/locale_toggle_controller.js
  • Create: app/views/static/_language_toggle.html.erb

  • Step 1: Add toggle labels to locale files

Append to config/locales/static.pt-BR.yml under static::

    navbar:
      language_toggle:
        aria_label: "Selecionar idioma"
        en_label: "English"
        pt_label: "Português (Brasil)"

And to config/locales/static.en.yml:

    navbar:
      language_toggle:
        aria_label: "Select language"
        en_label: "English"
        pt_label: "Português (Brasil)"
  • Step 2: Write the Stimulus controller

app/javascript/controllers/locale_toggle_controller.js:

import { Controller } from "@hotwired/stimulus"

const COOKIE_NAME    = "locale"
const STORAGE_KEY    = "lifehub_locale"
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365  // 1 year
const SUPPORTED      = ["en", "pt-BR"]

export default class extends Controller {
  static values = { current: String }

  connect() {
    this.reconcile()
  }

  switch(event) {
    const target = event.currentTarget.dataset.locale
    if (!SUPPORTED.includes(target)) return
    this.writeCookie(target)
    this.writeStorage(target)
    if (window.Turbo && Turbo.visit) {
      Turbo.visit(window.location.href, { action: "replace" })
    } else {
      window.location.reload()
    }
  }

  reconcile() {
    const cookieVal  = this.readCookie()
    const storageVal = this.readStorage()

    if (storageVal && SUPPORTED.includes(storageVal) && storageVal !== cookieVal) {
      // localStorage wins (treated as user-explicit memory).
      this.writeCookie(storageVal)
      if (storageVal !== this.currentValue) {
        if (window.Turbo && Turbo.visit) {
          Turbo.visit(window.location.href, { action: "replace" })
        } else {
          window.location.reload()
        }
      }
      return
    }

    if (cookieVal && SUPPORTED.includes(cookieVal) && cookieVal !== storageVal) {
      this.writeStorage(cookieVal)
    }
  }

  readCookie() {
    const match = document.cookie.match(/(?:^|;\s*)locale=([^;]+)/)
    return match ? decodeURIComponent(match[1]) : null
  }

  writeCookie(value) {
    const secure = window.location.protocol === "https:" ? "; secure" : ""
    document.cookie = `${COOKIE_NAME}=${encodeURIComponent(value)}; path=/; max-age=${COOKIE_MAX_AGE}; samesite=lax${secure}`
  }

  readStorage() {
    try { return window.localStorage.getItem(STORAGE_KEY) } catch (_) { return null }
  }

  writeStorage(value) {
    try { window.localStorage.setItem(STORAGE_KEY, value) } catch (_) { /* ignore quota errors */ }
  }
}
  • Step 3: Write the partial

app/views/static/_language_toggle.html.erb:

<div data-controller="locale-toggle"
     data-locale-toggle-current-value="<%= I18n.locale %>"
     class="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white p-0.5"
     role="group"
     aria-label="<%= t("static.navbar.language_toggle.aria_label") %>">
  <button type="button"
          data-action="locale-toggle#switch"
          data-locale="en"
          aria-label="<%= t("static.navbar.language_toggle.en_label") %>"
          aria-pressed="<%= I18n.locale == :en %>"
          class="px-2 py-1 text-sm rounded-md transition-colors <%= I18n.locale == :en ? 'bg-gray-900 text-white' : 'text-gray-500 hover:bg-gray-100' %>">
    🇺🇸 <span class="sr-only sm:not-sr-only sm:ml-0.5">EN</span>
  </button>
  <button type="button"
          data-action="locale-toggle#switch"
          data-locale="pt-BR"
          aria-label="<%= t("static.navbar.language_toggle.pt_label") %>"
          aria-pressed="<%= I18n.locale == :'pt-BR' %>"
          class="px-2 py-1 text-sm rounded-md transition-colors <%= I18n.locale == :'pt-BR' ? 'bg-gray-900 text-white' : 'text-gray-500 hover:bg-gray-100' %>">
    🇧🇷 <span class="sr-only sm:not-sr-only sm:ml-0.5">PT</span>
  </button>
</div>
  • Step 4: Verify completeness test still passes
bin/rails test test/i18n/static_translations_test.rb -v

Expected: pass.

  • Step 5: Commit
git add app/javascript/controllers/locale_toggle_controller.js \
        app/views/static/_language_toggle.html.erb \
        config/locales/static.en.yml \
        config/locales/static.pt-BR.yml
git commit -m "Add locale toggle Stimulus controller and partial"

Task 8: Translate static layout (lang, title, meta, hreflang)

Files:

  • Modify: app/views/layouts/static.html.erb

  • Step 1: Update the layout

Replace the existing <html>, <title>, and meta-description tags with locale-aware versions. The full updated <head> section:

<!DOCTYPE html>
<html lang="<%= I18n.locale %>" class="h-full">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title><%= content_for?(:title) ? yield(:title) : t("static.meta.default_title") %></title>
    <meta name="description" content="<%= content_for?(:description) ? yield(:description) : t("static.meta.default_description") %>">

    <!-- hreflang alternates -->
    <link rel="alternate" hreflang="en" href="<%= request.original_url %>">
    <link rel="alternate" hreflang="pt-BR" href="<%= request.original_url %>">
    <link rel="alternate" hreflang="x-default" href="<%= request.original_url %>">

    <!-- Open Graph -->
    <meta property="og:type" content="website">
    <meta property="og:url" content="<%= request.original_url %>">
    <meta property="og:title" content="<%= content_for?(:title) ? yield(:title) : t("static.meta.default_title") %>">
    <meta property="og:description" content="<%= content_for?(:description) ? yield(:description) : t("static.meta.og_description") %>">
    <%# ... rest of og/twitter unchanged ... %>

    <!-- Twitter -->
    <meta property="twitter:card" content="summary_large_image">
    <meta property="twitter:url" content="<%= request.original_url %>">
    <meta property="twitter:title" content="<%= content_for?(:title) ? yield(:title) : t("static.meta.default_title") %>">
    <meta property="twitter:description" content="<%= content_for?(:description) ? yield(:description) : t("static.meta.twitter_description") %>">

Leave the rest of the layout (favicon, stylesheets, GTM, body) untouched.

  • Step 2: Tighten the dual-track test added in Task 4

Update the test in test/controllers/static_controller_test.rb to assert on <html lang>:

test "GET / sends html lang=en for English visitors" do
  get root_url, headers: { "Accept-Language" => "en-US,en;q=0.9" }
  assert_response :success
  assert_match(/<html[^>]*lang="en"/, response.body)
end

test "GET / sends html lang=pt-BR for Portuguese visitors" do
  get root_url, headers: { "Accept-Language" => "pt-BR" }
  assert_response :success
  assert_match(/<html[^>]*lang="pt-BR"/, response.body)
end
  • Step 3: Run tests
bin/rails test test/controllers/static_controller_test.rb -v

Expected: pass.

  • Step 4: Commit
git add app/views/layouts/static.html.erb test/controllers/static_controller_test.rb
git commit -m "Localize static layout (lang, title, meta, hreflang)"

Task 9: Translate _navbar.html.erb (and mount toggle)

Files:

  • Modify: app/views/static/_navbar.html.erb
  • Modify: config/locales/static.en.yml
  • Modify: config/locales/static.pt-BR.yml

  • Step 1: Add navbar keys

Append to config/locales/static.pt-BR.yml under static.navbar::

      links:
        features: "Funcionalidades"
        vision: "Visão"
        focus: "Foco"
        community: "Comunidade"
        gamer: "Painel Gamer"
        achievements: "Conquistas"
        pricing: "Preços"
        app: "App"
        mcp_badge: "Novo"
      cta:
        sign_in: "Entrar"
        sign_up: "Começar grátis"
      mobile:
        menu_label: "Menu"

config/locales/static.en.yml:

      links:
        features: "Features"
        vision: "Vision"
        focus: "Focus"
        community: "Community"
        gamer: "Gamer Panel"
        achievements: "Achievements"
        pricing: "Pricing"
        app: "App"
        mcp_badge: "New"
      cta:
        sign_in: "Sign in"
        sign_up: "Get started free"
      mobile:
        menu_label: "Menu"
  • Step 2: Replace hardcoded strings in _navbar.html.erb

Each Funcionalidades<%= t("static.navbar.links.features") %>, etc. Mount the toggle right before the CTA <div class="hidden sm:flex...">:

<%= render "static/language_toggle" %>

The mobile menu's anchor labels and the "Novo" badges use the same keys.

  • Step 3: Run tests
bin/rails test test/i18n/static_translations_test.rb test/controllers/static_controller_test.rb -v

Expected: pass.

  • Step 4: Visual smoke
bin/rails server &
sleep 3
curl -s -H "Accept-Language: en-US" http://localhost:3000/ | grep -o "Get started free" | head -1
curl -s -H "Accept-Language: pt-BR"  http://localhost:3000/ | grep -o "Começar grátis"  | head -1
kill %1

Expected: each grep returns one match.

  • Step 5: Commit
git add app/views/static/_navbar.html.erb config/locales/static.en.yml config/locales/static.pt-BR.yml
git commit -m "Localize static navbar and mount language toggle"

Task 10: Translate _footer.html.erb

Files:

  • Modify: app/views/static/_footer.html.erb
  • (Locale keys already migrated in Task 6; only the tagline and sections.* keys may be new.)

  • Step 1: Replace hardcoded strings

Following the workflow at the top. Each hardcoded string → t("static.footer.*") or t("static.navbar.links.*") (reused) as appropriate. For the copyright line:

<p class="text-xs text-gray-400 text-center">
  <%= t("static.footer.copyright_html",
        year: Time.current.year,
        rights: t("static.footer.all_rights_reserved")).html_safe %>
</p>
  • Step 2: Run tests
bin/rails test test/i18n/static_translations_test.rb test/controllers/static_controller_test.rb -v
  • Step 3: Commit
git add app/views/static/_footer.html.erb config/locales/static.en.yml config/locales/static.pt-BR.yml
git commit -m "Localize static footer"

Task 11: Translate index.html.erb — HERO and YEAR HEATMAP sections

Files:

  • Modify: app/views/static/index.html.erb (lines 1–333)

The landing page is 1279 lines. We translate it in batches by section. Section boundaries follow the existing <!-- SECTION --> comments (verified at lines 6, 322, 333, 362, 394, 419, 455, 608, 622, 634, 648, 660, 671, 779, 870, 920).

  • Step 1: Replace hardcoded strings in HERO (lines 1–321) and YEAR HEATMAP (322–332)

Add keys under static.landing.hero and ensure static.landing.year_heatmap (migrated in Task 6) covers headline/subhead. Specifically translate:

  • content_for :title and content_for :description at lines 1–2 → reference t("static.meta.default_title") etc. (or keep page-specific override keys static.landing.meta.title).
  • Hero badge "Plataforma completa de gestão pessoal" (line 17) → static.landing.hero.badge.
  • <h1> "Organize sua vida. / Simplifique." (lines 20–23) → static.landing.hero.headline_line_1, headline_line_2.
  • Subhead "Contas, investimentos, despesas…" (line 25) → static.landing.hero.subhead.
  • CTA "Começar grátis" (line 31) → reuse static.landing.cta.start_free.
  • "Ver funcionalidades" link text (line 35) → static.landing.hero.see_features.
  • Mockup-internal strings (the demo dashboard at lines 40–321) — these are stylized fake data ("Painel", "Visão geral", "USD/BRL R$ 5,26 +0,62%", "Finanças", "Pessoal", etc.). Treat them as branding screenshot, not translation. Wrap them with a comment marker:
    <%# i18n-skip: mockup demo data %>
    

    Add the marker once near line 40 before the <!-- Dashboard Preview --> block — it covers the whole block.

  • YEAR HEATMAP section uses static.landing.year_heatmap.* already.

Add corresponding keys to both YAML files. Examples:

static.pt-BR.yml:

      hero:
        badge: "Plataforma completa de gestão pessoal"
        headline_line_1: "Organize sua vida."
        headline_line_2: "Simplifique."
        subhead: "Contas, investimentos, despesas, metas, hábitos, tarefas e muito mais em uma só plataforma. Feito para quem leva organização a sério."
        see_features: "Ver funcionalidades"
      meta:
        title: "Lifehub — Organize sua vida financeira"
        description: "Controle contas, investimentos, despesas e metas. Simule crescimento, planeje compras e domine o futuro financeiro da sua família."

static.en.yml:

      hero:
        badge: "Complete personal-management platform"
        headline_line_1: "Organize your life."
        headline_line_2: "Simplify."
        subhead: "Accounts, investments, expenses, goals, habits, tasks, and much more in one platform. Built for people who take organization seriously."
        see_features: "See features"
      meta:
        title: "Lifehub — Organize your financial life"
        description: "Manage accounts, investments, expenses, and goals. Simulate growth, plan purchases, and master your family's financial future."

Lines 1–2 of index.html.erb become:

<% content_for :title,       t("static.landing.meta.title") %>
<% content_for :description, t("static.landing.meta.description") %>
  • Step 2: Run tests + smoke
bin/rails test test/i18n/static_translations_test.rb test/controllers/static_controller_test.rb -v
  • Step 3: Commit
git add app/views/static/index.html.erb config/locales/static.en.yml config/locales/static.pt-BR.yml
git commit -m "Localize landing hero and year-heatmap sections"

Task 12: Translate index.html.erb — APP PREVIEWS, FUNCIONALIDADES, COMO FUNCIONA, DIFERENCIAIS

Files:

  • Modify: app/views/static/index.html.erb (lines 333–454)

  • Step 1: Translate per the workflow

Add namespaces under static.landing:

  • previews (carousel section)
  • features (FUNCIONALIDADES grid)
  • how_it_works (COMO FUNCIONA steps)
  • differentiators (DIFERENCIAIS)

Replace strings in lines 333–454 with t("static.landing.<section>.<key>"). Read the file to enumerate strings; common entries include:

  • Section eyebrows ("Funcionalidades", "Como funciona", "Diferenciais") → *.tag.
  • Section headlines → *.headline.
  • Section subheads → *.subhead.
  • Individual feature card titles/descriptions → features.cards.<name>.title / *.description.
  • "Etapa 1/2/3" labels → how_it_works.steps.<n>.tag / *.title / *.description.

For demo screenshots inside <div class="mockup-..." containers, add <%# i18n-skip: mockup demo data %> markers.

Mirror keys in both YAML files. The features.cards.* entries should be sorted alphabetically by card name for stability.

  • Step 2: Run tests + smoke
bin/rails test test/i18n/static_translations_test.rb test/controllers/static_controller_test.rb -v
  • Step 3: Commit
git add app/views/static/index.html.erb config/locales/static.en.yml config/locales/static.pt-BR.yml
git commit -m "Localize landing previews/features/how-it-works/differentiators"

Task 13: Translate index.html.erb — GAMIFICAÇÃO, VISION, FOCUS, LEADERBOARD, GAMER, LEVELS & BADGES

Files:

  • Modify: app/views/static/index.html.erb (lines 455–670)

  • Step 1: Translate per the workflow

These section partials are mostly already migrated in Task 6 (vision, focus_timer, leaderboard, gamer_dashboard, levels_badges). What remains in index.html.erb is the inline section wrapper copy (sub-heads, badge labels, ranking demo data).

For each <!-- VISION -->, <!-- FOCUS TIMER -->, etc. section in index.html.erb:

  • Wrap mockup blocks with <%# i18n-skip: mockup demo data %>.
  • For section eyebrow / headline / subhead, use the migrated static.landing.<section>.* keys directly.
  • Add any newly-discovered keys (e.g., card hover text, bullet descriptions) under static.landing.<section>.*.

For the GAMIFICAÇÃO section specifically (lines 455–607) — most of its content is a profile mockup. Add i18n-skip for the mock profile card and translate only the section eyebrow ("Gamificação"), headline, and subhead under static.landing.gamification.*.

  • Step 2: Run tests + smoke
bin/rails test test/i18n/static_translations_test.rb test/controllers/static_controller_test.rb -v
  • Step 3: Commit
git add app/views/static/index.html.erb config/locales/static.en.yml config/locales/static.pt-BR.yml
git commit -m "Localize landing gamification + section wrappers"

Task 14: Translate index.html.erb — MCP, PRECOS, APP DOWNLOAD, CTA FINAL

Files:

  • Modify: app/views/static/index.html.erb (lines 671–1279)

  • Step 1: Translate per the workflow

Namespaces:

  • static.landing.mcp_section.* (MCP section inside the landing page — separate from full mcp.html.erb)
  • static.landing.pricing_section.* (PRECOS section)
  • static.landing.app_download.*
  • static.landing.final_cta.*

For the MCP terminal mockup (lines 683–711), tool identifiers stay as-is — wrap the mockup block with <%# i18n-skip: terminal mockup %>. Translate surrounding copy (the bullets at lines 712–762 and the client logos at 763–778 only have alt attributes).

For PRECOS (lines 779–869), pricing tiers usually have language-specific names. Translate tier titles, feature bullets, and CTAs but keep currency formatting intact (use number_to_currency if not already).

Replace strings, mirror keys, run tests, commit.

  • Step 2: Run tests + smoke
bin/rails test test/i18n/static_translations_test.rb test/controllers/static_controller_test.rb -v
  • Step 3: Commit
git add app/views/static/index.html.erb config/locales/static.en.yml config/locales/static.pt-BR.yml
git commit -m "Localize landing MCP/pricing/app/CTA-final sections"

Task 15: Translate section partials

Files:

  • Modify: app/views/static/_landing_section.html.erb
  • Modify: app/views/static/_vision_section_card.html.erb
  • Modify: app/views/static/_levels_badges_strip.html.erb
  • Modify: app/views/static/_tool_card.html.erb
  • Modify: app/views/static/_year_heatmap.html.erb
  • Modify: app/views/static/_nav_item.html.erb
  • Modify: app/views/static/_focus_timer_preview.html.erb
  • Modify: app/views/static/_gamer_dashboard_preview.html.erb
  • Modify: app/views/static/_leaderboard_preview.html.erb
  • Modify: app/views/static/_vision_preview.html.erb
  • Modify: app/views/static/_preview_accounts.html.erb
  • Modify: app/views/static/_preview_expenses.html.erb
  • Modify: app/views/static/_preview_goals.html.erb
  • Modify: app/views/static/_preview_habits.html.erb
  • Modify: app/views/static/_preview_investments.html.erb

The _*_mockup partials (_focus_timer_mockup, _gamer_dashboard_mockup, _leaderboard_mockup, _vision_mockup) contain only mockup demo data. Add <%# i18n-skip: mockup demo data %> at the top of each instead of translating.

  • Step 1: For each non-mockup partial above, follow the translation workflow

Group keys under static.landing.partials.<partial_name>.* (e.g., static.landing.partials.tool_card.cta).

Mockup partials: add the i18n-skip comment and commit.

  • Step 2: Run tests + smoke
bin/rails test test/i18n/static_translations_test.rb test/controllers/static_controller_test.rb -v
  • Step 3: Commit
git add app/views/static/_*.html.erb config/locales/static.en.yml config/locales/static.pt-BR.yml
git commit -m "Localize landing partials and mark mockups as i18n-skip"

Task 16: Translate pricing.html.erb

Files:

  • Modify: app/views/static/pricing.html.erb

Note: pricing.html.erb is 3 lines (likely a render of a partial — verify). If it renders the landing pricing section, you may have nothing to do here beyond confirmation.

  • Step 1: Inspect and translate
cat /code/life-management/app/views/static/pricing.html.erb

If the file just renders a partial, ensure that partial is already translated (Task 14/15). Otherwise, follow the workflow on its content.

  • Step 2: Run tests + smoke
bin/rails test test/i18n/static_translations_test.rb -v
  • Step 3: Commit (if changed)
git add app/views/static/pricing.html.erb config/locales/static.en.yml config/locales/static.pt-BR.yml
git commit -m "Localize pricing page"

If nothing changed, skip the commit.


Files:

  • Modify: app/views/static/_legal_page.html.erb
  • Modify: app/views/static/terms.html.erb
  • Modify: app/views/static/privacy.html.erb
  • Modify: config/locales/static.en.yml
  • Modify: config/locales/static.pt-BR.yml

  • Step 1: Add the disclaimer key

config/locales/static.pt-BR.yml under static.legal:

    legal:
      english_draft_notice: ""   # empty; banner is hidden for pt-BR
      terms:
        title: "Termos de Uso"
        last_updated: "Última atualização: %{date}"
        body_html: |
          <!-- PT-BR terms body migrated verbatim from terms.html.erb -->
      privacy:
        title: "Política de Privacidade"
        last_updated: "Última atualização: %{date}"
        body_html: |
          <!-- PT-BR privacy body migrated verbatim from privacy.html.erb -->

config/locales/static.en.yml under static.legal:

    legal:
      english_draft_notice: "This English translation is provided for convenience; the Portuguese version is authoritative."
      terms:
        title: "Terms of Use"
        last_updated: "Last updated: %{date}"
        body_html: |
          <!-- EN literal translation of terms.html.erb body. Marked draft for counsel review. -->
      privacy:
        title: "Privacy Policy"
        last_updated: "Last updated: %{date}"
        body_html: |
          <!-- EN literal translation of privacy.html.erb body. Marked draft for counsel review. -->

The actual body_html content is moved verbatim from terms.html.erb and privacy.html.erb. For English: produce a literal translation of the Portuguese text. Add a comment in the EN file: # Draft translation — counsel review required before public launch.

  • Step 2: Refactor _legal_page.html.erb to render translated body
<% locale_is_en = (I18n.locale == :en) %>
<% if locale_is_en && t("static.legal.english_draft_notice").present? %>
  <div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 mb-6">
    <%= t("static.legal.english_draft_notice") %>
  </div>
<% end %>
<article class="prose prose-gray max-w-none">
  <h1><%= title %></h1>
  <p class="text-sm text-gray-500"><%= t("static.legal.#{page}.last_updated", date: last_updated) %></p>
  <%= t("static.legal.#{page}.body_html").html_safe %>
</article>

Where title, page, last_updated are passed as locals from terms.html.erb and privacy.html.erb.

  • Step 3: Update terms.html.erb and privacy.html.erb
<% content_for :title, t("static.legal.terms.title") %>
<%= render "static/legal_page",
           title: t("static.legal.terms.title"),
           page: "terms",
           last_updated: "01/01/2026" %>

Adjust the existing last_updated value to whatever the current terms.html.erb shows. Same shape for privacy.html.erb (with page: "privacy").

  • Step 4: Run tests + smoke
bin/rails test test/i18n/static_translations_test.rb test/controllers/static_controller_test.rb -v

Smoke: visit /terms and /privacy with both Accept-Language headers. Confirm:

  • pt-BR: original Portuguese text, no banner.
  • en: English translation with the amber draft banner at the top.

  • Step 5: Commit
git add app/views/static/_legal_page.html.erb \
        app/views/static/terms.html.erb \
        app/views/static/privacy.html.erb \
        config/locales/static.en.yml \
        config/locales/static.pt-BR.yml
git commit -m "Localize legal pages with English-draft disclaimer banner"

Task 18: Translate mcp.html.erb

Files:

  • Modify: app/views/static/mcp.html.erb

  • Step 1: Follow the workflow

Keys under static.mcp.*. Tool identifiers (e.g., create_expense, list_habits) stay literal — they are stable API names. Mark any code/terminal mockup blocks with <%# i18n-skip: terminal mockup %>.

mcp.html.erb references anchors like #configuracao (Portuguese fragment). Existing tests assert these — leave the anchor IDs untouched to avoid breaking the existing test/controllers/static_controller_test.rb:22-24 assertions.

  • Step 2: Run tests + smoke
bin/rails test test/i18n/static_translations_test.rb test/controllers/static_controller_test.rb -v

Expected: pass (anchor ID assertions still match).

  • Step 3: Commit
git add app/views/static/mcp.html.erb config/locales/static.en.yml config/locales/static.pt-BR.yml
git commit -m "Localize MCP marketing page"

Task 19: Translate mcp_tools.html.erb

Files:

  • Modify: app/views/static/mcp_tools.html.erb

  • Step 1: Follow the workflow

Keys under static.mcp_tools.*. Each tool group has a heading (e.g., "Finanças") — translate the group name under static.mcp_tools.groups.<group_slug>.title and *.description. Tool names themselves stay literal.

  • Step 2: Run tests + smoke
bin/rails test test/i18n/static_translations_test.rb test/controllers/static_controller_test.rb -v
  • Step 3: Commit
git add app/views/static/mcp_tools.html.erb config/locales/static.en.yml config/locales/static.pt-BR.yml
git commit -m "Localize MCP tools catalog page"

Task 20: Convert welcome.md to ERB view

Files:

  • Create: app/views/static/welcome.html.erb
  • Modify: app/controllers/static_controller.rb
  • Modify: config/routes.rb
  • Modify: config/locales/static.en.yml
  • Modify: config/locales/static.pt-BR.yml
  • Delete (move to git history): docs/welcome.md

  • Step 1: Read the existing welcome.md
cat /code/life-management/docs/welcome.md
  • Step 2: Add translation keys

config/locales/static.pt-BR.yml:

    welcome:
      title: "Boas-vindas ao Lifehub"
      body_html: |
        <!-- Full PT-BR HTML body, converted from welcome.md to Tailwind-styled HTML -->
        <h2>...</h2>
        <p>...</p>

config/locales/static.en.yml:

    welcome:
      title: "Welcome to Lifehub"
      body_html: |
        <!-- Full EN HTML body, hand-translated from PT-BR -->
        <h2>...</h2>
        <p>...</p>

Convert the existing Markdown to inlined HTML manually (Kramdown can be used as a one-off helper: bin/rails runner 'puts Kramdown::Document.new(File.read("docs/welcome.md"), input: "GFM").to_html'). Keep the HTML simple — <h2>, <h3>, <p>, <ul>, <a>. Tailwind prose styling is applied by the template wrapper.

  • Step 3: Create the view

app/views/static/welcome.html.erb:

<% content_for :title, t("static.welcome.title") %>
<div class="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 py-20">
  <article class="prose prose-gray max-w-none">
    <h1><%= t("static.welcome.title") %></h1>
    <%= t("static.welcome.body_html").html_safe %>
  </article>
</div>
  • Step 4: Add the controller action and route

app/controllers/static_controller.rb — add after the mcp_tools action:

##
# Welcome page (was docs/welcome.md)
def welcome
end

config/routes.rb — add near the other static routes (around line 240):

get "welcome", to: "static#welcome"
  • Step 5: Delete the markdown source
git rm /code/life-management/docs/welcome.md

This breaks /documentation/welcome for the public route — that's intentional. The route still exists but the parser won't find the file. Add an integration test that asserts the new route works.

test/controllers/static_controller_test.rb:

test "GET /welcome renders pt-BR by default" do
  get welcome_url
  assert_response :success
  assert_match(/Boas-vindas ao Lifehub/, response.body)
end

test "GET /welcome with Accept-Language en renders English" do
  get welcome_url, headers: { "Accept-Language" => "en-US,en;q=0.9" }
  assert_response :success
  assert_match(/Welcome to Lifehub/, response.body)
end
  • Step 6: Run tests + smoke
bin/rails test test/i18n/static_translations_test.rb test/controllers/static_controller_test.rb -v
  • Step 7: Commit
git add app/views/static/welcome.html.erb \
        app/controllers/static_controller.rb \
        config/routes.rb \
        config/locales/static.en.yml \
        config/locales/static.pt-BR.yml \
        docs/welcome.md \
        test/controllers/static_controller_test.rb
git commit -m "Convert welcome.md to bilingual ERB view at /welcome"

Task 21: Convert lifehub_plan.md to ERB view

Files:

  • Create: app/views/static/lifehub_plan.html.erb
  • Modify: app/controllers/static_controller.rb
  • Modify: config/routes.rb
  • Modify: config/locales/static.en.yml
  • Modify: config/locales/static.pt-BR.yml
  • Delete: docs/lifehub_plan.md

Mirror Task 20 with lifehub_plan substituted for welcome. Add an integration test for lifehub_plan_url in both locales.

  • Step 1: Read source
cat /code/life-management/docs/lifehub_plan.md
  • Step 2–7: Same pattern as Task 20

Final commit:

git commit -m "Convert lifehub_plan.md to bilingual ERB view at /lifehub_plan"

Task 22: Devise signup carry-over

Files:

  • Modify: app/views/devise/registrations/new.html.erb
  • Create: app/javascript/controllers/signup_locale_controller.js
  • Modify: app/controllers/users/registrations_controller.rb
  • Modify: test/controllers/users/registrations_controller_test.rb

  • Step 1: Write the failing controller tests

Add to test/controllers/users/registrations_controller_test.rb:

test "signup with signup_locale=en persists finance_settings locale=en" do
  post user_registration_path, params: {
    user: {
      email: "[email protected]",
      password: "password123",
      password_confirmation: "password123",
      signup_locale: "en"
    }
  }
  user = User.find_by(email: "[email protected]")
  assert_not_nil user
  assert_equal "en", user.finance_settings["locale"]
  assert_equal "USD", user.finance_currency
end

test "signup with signup_locale=pt-BR persists finance_settings locale=pt-BR" do
  post user_registration_path, params: {
    user: {
      email: "[email protected]",
      password: "password123",
      password_confirmation: "password123",
      signup_locale: "pt-BR"
    }
  }
  user = User.find_by(email: "[email protected]")
  assert_not_nil user
  assert_equal "pt-BR", user.finance_settings["locale"]
  assert_equal "BRL", user.finance_currency
end

test "signup with invalid signup_locale is ignored" do
  post user_registration_path, params: {
    user: {
      email: "[email protected]",
      password: "password123",
      password_confirmation: "password123",
      signup_locale: "fr"
    }
  }
  user = User.find_by(email: "[email protected]")
  assert_not_nil user
  # Default: finance_settings may be empty or have no "locale" key → finance_locale falls back to "pt-BR"
  assert_equal "pt-BR", user.finance_locale
end

test "signup without signup_locale param uses default" do
  post user_registration_path, params: {
    user: {
      email: "[email protected]",
      password: "password123",
      password_confirmation: "password123"
    }
  }
  user = User.find_by(email: "[email protected]")
  assert_not_nil user
  assert_equal "pt-BR", user.finance_locale
end
  • Step 2: Run failing tests
bin/rails test test/controllers/users/registrations_controller_test.rb -v

Expected: the new four tests fail (controller not yet wired).

  • Step 3: Override the controller

Modify app/controllers/users/registrations_controller.rb. Replace the create method:

SUPPORTED_SIGNUP_LOCALES = %w[en pt-BR].freeze

def create
  super do |resource|
    apply_signup_locale(resource)
    if resource.persisted? && !resource.active_for_authentication?
      notice = flash[:notice]
      flash.discard(:notice)
      flash[:sticky_notice] = notice
    end
  end
end

private

def apply_signup_locale(resource)
  return unless resource.persisted?
  locale = params.dig(:user, :signup_locale).to_s
  return unless SUPPORTED_SIGNUP_LOCALES.include?(locale)
  settings = (resource.finance_settings || {}).merge("locale" => locale)
  resource.update_columns(finance_settings: settings)
end

(update_columns skips callbacks/validations — appropriate since we only set a JSON column. If finance_settings is a JSON DB column, this works.)

  • Step 4: Run tests to verify pass
bin/rails test test/controllers/users/registrations_controller_test.rb -v

Expected: all four new tests pass; existing tests still pass.

  • Step 5: Add hidden input to signup form

Modify app/views/devise/registrations/new.html.erb. Inside the form_for block, add immediately before <%= render "devise/shared/error_messages" %>:

<%= f.hidden_field :signup_locale,
                    data: { signup_locale_target: "input" } %>

Wrap the entire form_for block with the Stimulus controller. Change line 34:

<%= form_for(resource, as: resource_name, url: registration_path(resource_name),
             html: { class: "space-y-4", data: { controller: "signup-locale" } }) do |f| %>
  • Step 6: Write the Stimulus controller

app/javascript/controllers/signup_locale_controller.js:

import { Controller } from "@hotwired/stimulus"

const STORAGE_KEY = "lifehub_locale"
const SUPPORTED   = ["en", "pt-BR"]

export default class extends Controller {
  static targets = ["input"]

  connect() {
    if (!this.hasInputTarget) return
    let value
    try { value = window.localStorage.getItem(STORAGE_KEY) } catch (_) { value = null }
    if (SUPPORTED.includes(value)) {
      this.inputTarget.value = value
    }
  }
}
  • Step 7: Run tests + manual smoke
bin/rails test test/controllers/users/registrations_controller_test.rb -v
bin/rails server &
sleep 3
# Visit /users/sign_up in browser, toggle to EN, sign up, check finance_settings.
  • Step 8: Commit
git add app/views/devise/registrations/new.html.erb \
        app/javascript/controllers/signup_locale_controller.js \
        app/controllers/users/registrations_controller.rb \
        test/controllers/users/registrations_controller_test.rb
git commit -m "Carry visitor locale into finance_settings on signup"

Task 23: Hardcoded-string sweep

Files:

  • All files in app/views/static/ and app/views/layouts/static.html.erb.

  • Step 1: Grep for Portuguese giveaways

cd /code/life-management
grep -rn --include="*.erb" -E "(ção|çã|áticos|Começar|Visão|Termos|Privacidade|Funcionalidades|Política|Configuração|Conquistas)" app/views/static app/views/layouts/static.html.erb

Each hit must be either:

  • Inside an <%# i18n-skip ... %> block (intentional mockup data).
  • An anchor href="#fragment" (URL fragments aren't translated).
  • A typo / leftover hardcoded string — fix it.

  • Step 2: Grep for English leak indicators in pt-BR locale-only file paths
grep -rn --include="*.erb" -E "\b(Get started|Sign in|Sign up|Features|Pricing|Privacy|Terms|Welcome)\b" app/views/static

These should all be inside t(...) calls or skip markers — otherwise hardcoded English crept in.

  • Step 3: Fix any findings, re-run tests
bin/rails test test/controllers/static_controller_test.rb test/i18n/static_translations_test.rb -v
  • Step 4: Commit (if any fixes made)
git commit -am "Fix leftover hardcoded strings found in i18n sweep"

If no fixes, skip.


Task 24: Manual smoke pass

Files:

  • None modified.

  • Step 1: Boot the server and visit each URL in both locales

bin/rails server -p 3000 &
sleep 3

for url in / /pricing /terms /privacy /mcp-docs /mcp-tools /welcome /lifehub_plan; do
  echo "=== $url with Accept-Language: en ==="
  curl -s -H "Accept-Language: en-US" "http://localhost:3000$url" | head -c 200
  echo
  echo "=== $url with Accept-Language: pt-BR ==="
  curl -s -H "Accept-Language: pt-BR" "http://localhost:3000$url" | head -c 200
  echo
done

kill %1

Expected: page contents differ between locales for every URL.

  • Step 2: Toggle flow in browser

Open http://localhost:3000/ in a browser. Confirm:

  • The detected locale matches the browser's primary language.
  • Click 🇺🇸 → page reloads in English; cookie locale=en set; localStorage lifehub_locale=en set.
  • Click 🇧🇷 → reloads in Portuguese; cookie/localStorage updated.
  • Hard reload → preserved locale.
  • Clear cookies but keep localStorage → next page load reconciles via the Stimulus controller (cookie restored from localStorage, locale stays the user-chosen value).

  • Step 3: Signup carry-over flow

  • Visit /, toggle to 🇺🇸.
  • Click Sign up → land on /users/sign_up in English (the form layout uses static for guests — confirmed at users/registrations_controller.rb:53).
  • Submit the form. Inspect the created user in bin/rails console:
    User.last.finance_settings["locale"]   # => "en"
    User.last.finance_currency             # => "USD"
    
  • After login redirect, admin dashboard renders in English.

  • Step 4: Run the full suite
bin/rails test
bin/rails test:system 2>&1 | tail -30

Expected: all green (or no new failures vs Task 1 baseline).

  • Step 5: Commit a marker
git commit --allow-empty -m "chore: complete static-i18n smoke pass"

Self-review checklist

  • Resolver supports cookie/header/default priority (Task 2).
  • Concern writes cookie on every static request (Task 3).
  • Static controller wires concern (Task 4).
  • Locale files exist + completeness test enforces parity (Task 5).
  • Existing static: and footer: keys migrated (Task 6).
  • Toggle Stimulus controller + partial (Task 7).
  • Layout <html lang>, title, meta, hreflang (Task 8).
  • Navbar localized + toggle mounted (Task 9).
  • Footer localized (Task 10).
  • Landing page fully translated in 4 batches (Tasks 11–14).
  • Section partials translated, mockups marked skip (Task 15).
  • Pricing page localized (Task 16).
  • Legal pages localized + English-draft banner (Task 17).
  • MCP page localized (Task 18).
  • MCP tools catalog localized (Task 19).
  • welcome.md converted to ERB view + routes (Task 20).
  • lifehub_plan.md converted to ERB view + routes (Task 21).
  • Devise signup carry-over + tests (Task 22).
  • Hardcoded-string sweep (Task 23).
  • Manual smoke pass (Task 24).
  • Currency follows automatically — no model changes (verified in spec; User#finance_currency line 131).
  • Documentation::Parser and /documentation/:page route unchanged (non-goal honored).