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:
- Open the ERB file.
- 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 likelink_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
- Anchor IDs /
- For each string, pick a translation key under
static.<page>.<section>.<key>(e.g.,static.landing.hero.headline). Usesnake_caselowercase. - 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.,© ...) use the_htmlsuffix and<%= t("..._html") %>. - Add the key to both
config/locales/static.pt-BR.yml(PT value) andconfig/locales/static.en.yml(EN value) in sorted-ish order under the appropriate sub-namespace. - After finishing the file, run:
bin/rails test test/i18n/static_translations_test.rb bin/rails test test/controllers/static_controller_test.rbBoth must pass before commit.
- Visual smoke:
bin/rails serverand load the URL withAccept-Language: en-US,en;q=0.9andAccept-Language: pt-BR. Confirm no leftover Portuguese in English, no missing-translation placeholders (translation missing: ...). - 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"
Task 6: Migrate existing static: and footer: namespaces
Files:
- Modify:
config/locales/en.yml(removestatic:block at lines ~2033–2039,footer:block at lines ~2137–2140) - Modify:
config/locales/pt-BR.yml(removestatic: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.erbif they reference the oldstatic.*.titlekeys -
Modify: any other file referencing
t("footer.*")or top-levelt("static.*")(nott("static.landing.*"), which already lives understatic:) - 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: "© %{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: "© %{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 pagecomment through the end oflevels_badges.subheadvalue. - Lines containing the
# ── Footercomment through the end ofall_rights_reserved.
From config/locales/en.yml, delete:
- Lines containing the
# ── Staticcomment through the end ofstatic.terms.titlevalue. - Lines containing the
# ── Footercomment through the end ofall_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 forprivacy,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
taglineandsections.*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 :titleandcontent_for :descriptionat lines 1–2 → referencet("static.meta.default_title")etc. (or keep page-specific override keysstatic.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.
Task 17: Translate legal pages + add English-draft banner
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.erbto 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.erbandprivacy.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/andapp/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=enset; localStoragelifehub_locale=enset. - 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_upin English (the form layout usesstaticfor guests — confirmed atusers/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:andfooter: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_currencyline 131). Documentation::Parserand/documentation/:pageroute unchanged (non-goal honored).