14 KiB
FEATURE: FULL BILINGUAL (EN/FR) — retire.krisforbes.ca
Plan v1 — written 2026-10-09 by Hermes (GLM). Execution: DSH (local 27B), all stages, zero user interaction. User request (verbatim): "I also want this application to be available in english and french fully bilingual." This plan is stage A of a two-stage feature (stage B = public service pension module, see PS-PENSION-PLAN.md, runs after this one).
Goal
Make every user-facing string in the app available in English and French, with a visible
language toggle, persisted per visitor, included in shared links, with locale-correct number
formatting. The engine (site/engine/*.js) is presentation-neutral and must NOT change.
English behavior must remain functionally identical to today (mobile pass round 1+2 state is the baseline).
Current state (verified 2026-10-09, after mobile rounds 1+2)
- Live: https://retire.krisforbes.ca (Unraid
retireappnginx :8103, webroot/mnt/better/appdata/retireapp, Caddy + CF grey-cloud). Deploy:bash scripts/deploy.sh→DEPLOY-OK. - Repo:
gitea.krisforbes.ca/krisf/retire-planner, main @65863c0. Tests: engine 36/36, UI smoke OK. - Files:
site/index.html,site/app.js(questionnaire STEPS, panel, charts G0–G3, results, table, vision card),site/style.css,site/engine/{engine,canada}.js,site/vision/manifest.json+ images,tests/{engine.test.js,ui.smoke.js},scripts/deploy.sh. - localStorage key
retire-planner-v1holds{a: <answers>, step, screen}. Share links use#p=<payload>encoding the answers. - Header (global, sticky): logo, privacy pill, spacer,
Start over/Print/Copy linkghost buttons. - 14 questionnaire steps (single) / 17 (couple), conditional; persistent "Your plan so far" sheet with 4 stat chips + insight line; results with verdict, gap cards, vision card, 4 SVG charts, year-by-year table (11 cols), what-if scenarios, disclaimers, privacy box, footer.
- Vision card:
CAPTIONSobject in app.js (14 slugs incl.hero) +vision/manifest.json.
Architecture (implement exactly this shape)
- New file
site/i18n.js(dependency-free, loads beforeapp.js):var I18N = { en: {…}, fr: {…} }— nested objects, dot-path keys (e.g.panel.title,q.step1.title).function t(key, vars)— resolves dot path in current language; falls back toen, then to the key itself. Interpolates{name}placeholders fromvars.function getLang()/function setLang(l)—'en' | 'fr'; persists to state; callsapplyI18n()+ re-renders current screen.function detectLang()—navigator.languagestarts withfr→'fr', else'en'. Used only when no saved state exists.- Locale-aware formatters (use
Intl.NumberFormat):fmtMoney(n)→ en-CA$959,835/ fr-CA959 835 $(0 decimals, CAD)fmtMoneyK(n)→ en-CA$523K,$1.05M/ fr-CA523 k$,1,05 M$(compact, ≤3 significant digits)fmtInt(n)→959,835/959 835fmtPct(x)→5%/5 %(fr: space before %)- All chart axis/legend/annotation text and table cells must go through these (today they hand-roll
$523k).
function applyI18n()— walks[data-i18n="key"](sets textContent),[data-i18n-html="key"](innerHTML, for strings with markup),[data-i18n-attr="placeholder:key,aria-label:key2"]; setsdocument.documentElement.langanddocument.titleper language.
- Language state: add
langto the persisted state object ({a, step, screen, lang}). On load: payload lang > saved state lang >detectLang(). Share payload: includelangin the#p=payload; old payloads without it →en(backward compatible). - Header toggle: a compact two-button pill group
EN | FRin the header (all screens), active button styled,aria-pressed. Placed after the logo / before the spacer. On mobile it must fit the single-row header (round-2 layout: ≤52px, hide pill ≤700px, hide header Print ≤860px). - index.html: add
data-i18nattributes to every static text node (intro, privacy box, footer, disclaimers, panel header label, button labels, section headings, table headers are dynamic — see app.js). Add<script src="i18n.js"></script>beforeapp.js. Keep<!-- retire-planner v1 -->as first line in<head>. - app.js: replace every user-facing literal with
t()/ formatters:STEPSdefinitions: title/sub/options/hints/placeholders → keys.- Province names → keys (
prov.yt…prov.nu). - Panel: chips labels/values, insight line, "Your plan so far".
- Insights (all templates), gap cards, verdict text, what-if scenario names + descriptions.
- Chart titles/subtitles/axis labels/legends/annotations (G0–G3), incl. the
retiremarker,runs out ≈,shortfall from ~, G4 bar names + values. - Year table column headers; the collapsed
<summary>label. - Vision card:
CAPTIONSbecomes{ slug: {en, fr} };renderVisionpicks by current lang. - Validation/error messages ("Enter a value between {min} and {max}").
- Buttons: Next/Back/Start over/Print/Copy link/Edit answers/Start/Continue/Yes/No/optional tags.
- Share success/failure strings; print header if any.
- style.css: minor additions only if needed for the toggle + FR (e.g.,
.lang-togglestyles; allowhyphens:autoon FR paragraphs/cards if overflow appears — see risk section). No redesign.
Canadian French glossary (use these; official terminology where one exists)
| EN | FR |
|---|---|
| Retire Planner (title) | Planificateur de retraite |
| Your plan so far | Votre plan jusqu'ici |
| Savings at {age} (chip) | Épargne à {age} ans |
| Monthly need (chip) | Besoin mensuel |
| Shortfall (chip) | Manque à gagner |
| Surplus (chip) | Excédent |
| RRSP | REER (régime enregistré d'épargne-retraite) |
| TFSA | CELI (compte d'épargne libre d'impôt) |
| RRIF | FERR (fonds enregistré de revenu de retraite) |
| CPP | RPC (Régime de pensions du Canada) |
| OAS | SV (Sécurité de la vieillesse) |
| GIS | SRG (Supplément de revenu garanti) |
| YMPE | MGA (maximum des gains admissibles) |
| Age amount (credit) | Crédit pour âge |
| Pension income splitting | Fractionnement du revenu de pension |
| Pension income amount ($2,000) | Montant pour revenu de pension |
| Province names | Colombie-Britannique, Alberta, Saskatchewan, Manitoba, Ontario, Québec, Nouveau-Brunswick, Nouvelle-Écosse, Île-du-Prince-Édouard, Terre-Neuve-et-Labrador, Yukon, Territoires du Nord-Ouest, Nunavut |
| Single | Célibataire |
| Couple / common-law | Couple / conjoint de fait |
| Current age | Âge actuel |
| Annual income | Revenu annuel |
| Household | Ménage |
| Retirement age | Âge de la retraite |
| Spending goal | Objectif de dépenses |
| Monthly contribution | Cotisation mensuelle |
| Employer match | Contrepartie de l'employeur |
| Real return | Rendement réel |
| Inflation | Inflation |
| Life expectancy | Espérance de vie |
| Pension / rental / other income | Revenu de pension / locatif / autre |
| RRSP balance | Solde du REER |
| TFSA balance | Solde du CELI |
| Non-registered | Non enregistré |
| CPP start age | Âge de début de la RPC |
| OAS start age | Âge de début de la SV |
| Next / Back | Suivant / Précédent |
| Start over | Recommencer |
| Print / save PDF | Imprimer / enregistrer en PDF |
| Copy link | Copier le lien |
| Edit answers | Modifier les réponses |
| Start / Continue | Commencer / Continuer |
| Optional | Facultatif |
| Your savings journey (chart) | L'évolution de votre épargne |
| Retirement income by source (chart) | Revenu de retraite par source |
| Cash vs. planned spending (chart) | Encaisse vs dépenses prévues |
| Four futures (chart) | Quatre scénarios |
| Current plan (scenario) | Plan actuel |
| +$200/mo to RRSP (scenario) | +200 $/mois au REER |
| Retire at 63 (scenario) | Retraite à 63 ans |
| Spend $5k more/yr (scenario) | Dépenser 5 000 $ de plus par an |
| Year-by-year detail | Détail année par année |
| Try a what-if | Essayer un scénario |
| What-if scenarios | Scénarios « et si » |
| Age (table col) | Âge |
| Withdraw (col) | Retrait |
| Total (col) | Total |
| Non-reg (col) | Non enreg. |
| Tax + clawback (col) | Impôt + récup. |
| Unfunded (col) | Non financé |
| Estimates only — not financial advice | Estimations seulement — il ne s'agit pas de conseils financiers |
| 100% client-side — answers never leave your browser | 100 % côté client — vos réponses ne quittent jamais votre navigateur |
| Interests: dock/travel/garden/family/volunteering/learning/snowbird/hobbies/cottage/city/pets/grandkids/health/legacy | quai / voyages / jardinage / famille / bénévolat / apprentissage / snowbird (courant au Québec) / passe-temps / chalet / ville / animaux / petits-enfants / santé / héritage |
| Sources | Sources |
| (public service pension terms — stage B will need these too) | pension de la fonction publique, service ouvrant droit à pension, pension viagère, prestation de raccordement, allocation annuelle, pension différée, pension non réduite, réduction pour retraite anticipée, conjoint survivant, indexée à l'IPC, salaire moyen des cinq années consécutives les mieux payées, classification TI, groupe 1 / groupe 2, MMGP (moyenne des maximums des gains admissibles) |
Number formatting rules (fr-CA): thousands = narrow no-break space, decimal = comma,
percent has a space before %, currency symbol trails (959 835 $, 523 k$).
In SVG text the same formatters apply (1,05 M$ etc.).
Known risk: French is ~20–30% longer than English
Mitigate and verify:
- Chart SVG labels: keep FR labels concise; after implementing, assert programmatically that
every
<text>in each chart SVG hasgetBBox().widthwithin the 640-wide viewBox (allow the horizontally-scrollable.chartboxon mobile, but nothing may overflow the viewBox itself). - Mobile: re-run the mobile audit in FR at 320/360/390 (script: adapt
/home/ubuntu/.hermes/cache/scratch/mobile_audit.pyto setlang:'fr'in localStorage before load). The--sheet-hlive-sync from mobile round 2 handles panel height automatically. Check: no horizontal document overflow, chips readable, table scrolls, sticky CTA works, buttons fit. - Desktop FR screenshot at 1280px.
- If specific FR strings overflow, shorten the FR wording (never the EN) or allow wrapping — fix in CSS, not by truncating text.
Stages (execute in order; zero questions; every stage pre-approved)
- Preflight: confirm working tree clean at
65863c0; runnode tests/engine.test.js(36/36) andnode tests/ui.smoke.js(SMOKE-OK); capture EN baseline screenshots at 390/320 (reusemobile_audit.py→ copy toaudit/bilingual-en-baseline/). - i18n scaffold: create
site/i18n.js(dictionary with all keys for both languages — build it by walking index.html + app.js and extracting every string), formatters,t(), lang state,applyI18n(), share-payload lang, header toggle in index.html + style.css, script tag. - Wrap static HTML:
data-i18nattributes across index.html. - Wrap app.js: all dynamic strings via
t()/formatters (steps, panel, insights, charts, table, vision captions, scenarios, errors, share/print strings). VisionCAPTIONS→{slug:{en,fr}}. - Tests: new
tests/i18n.test.js— (a) deep key-set parity en↔fr; (b) no empty values; (c){placeholder}sets match across languages for every key; (d) formatter exact-output checks ($959,835/959 835 $/$1.05M/1,05 M$/5%/5 %); (e) FR render assertions: with lang=fr, DOM shows French for ~15 critical strings (title, panel label, a step title, a chip label, results heading, a chart title, a table header, the privacy line, the disclaimer). Extendtests/ui.smoke.jsto run its full assertion set in both languages (parameterize expected strings by lang; keep output endingSMOKE-OK). Engine tests untouched (36/36). - Deploy + verify:
bash scripts/deploy.sh→DEPLOY-OK; origin grep marker = 1. EN regression: fresh EN screenshots at 390/320 — functionally identical to baseline (spot-check via vision). FR: mobile audit variant at 320/360/390 + desktop 1280; programmatic checks: no horizontal overflow (document.documentElement.scrollWidth <= innerWidth + 1), chart text fits viewBox (getBBox assertion), sheet/chips/CTA checks pass. Off-LAN paramiko check (Midhurst troubleboxtrouble@100.96.20.61:2222, password candidates in~/projects/*.pylines containingb1tvvt): site 200 + marker. Save FR screenshots toaudit/bilingual-fr/. - Report + commit + push:
BILINGUAL-REPORT.md(what changed, glossary notes, test evidence, FR screenshots, known FR layout notes) +audit/evidence; commit + push tokrisf/retire-plannermain.
Hard constraints
- Do not modify
site/engine/engine.jsorsite/engine/canada.js(presentation-only change). - Keep
<!-- retire-planner v1 -->first line in<head>; keep the privacy statement's meaning intact in both languages. - No frameworks, no build step, no CDN.
deploy.shrsyncssite/—i18n.jsships automatically. - Desktop (≥861px) EN layout unchanged; FR may wrap slightly longer text (acceptable if nothing overflows horizontally).
- If a stage fails twice, stop, keep the last good deploy live, and report the blocker.
Definition of done
- Toggle EN|FR in header on every screen; persists across reload; survives in share links;
navigator.language=fr*defaults to FR for new visitors. - Every visible string renders in the selected language (EN and FR verified by screenshots + tests).
tests/i18n.test.jsgreen (parity/placeholders/formatters/FR render),tests/ui.smoke.jsgreen in both languages, engine 36/36 untouched.- FR mobile verified at 320/360/390 + desktop; no horizontal overflow; charts fit viewBox.
- Deployed, off-LAN 200, committed + pushed with
BILINGUAL-REPORT.md+ audit evidence. - Your FINAL output line must be exactly:
BILINGUAL_DONE