# 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 `retireapp` nginx :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-v1` holds `{a: , step, screen}`. Share links use `#p=` encoding the answers. - Header (global, sticky): logo, privacy pill, spacer, `Start over` / `Print` / `Copy link` ghost 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: `CAPTIONS` object in app.js (14 slugs incl. `hero`) + `vision/manifest.json`. ## Architecture (implement exactly this shape) 1. **New file `site/i18n.js`** (dependency-free, loads before `app.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 to `en`, then to the key itself. Interpolates `{name}` placeholders from `vars`. - `function getLang()` / `function setLang(l)` — `'en' | 'fr'`; persists to state; calls `applyI18n()` + re-renders current screen. - `function detectLang()` — `navigator.language` starts with `fr` → `'fr'`, else `'en'`. Used only when no saved state exists. - Locale-aware formatters (use `Intl.NumberFormat`): - `fmtMoney(n)` → en-CA `$959,835` / fr-CA `959 835 $` (0 decimals, CAD) - `fmtMoneyK(n)` → en-CA `$523K`, `$1.05M` / fr-CA `523 k$`, `1,05 M$` (compact, ≤3 significant digits) - `fmtInt(n)` → `959,835` / `959 835` - `fmtPct(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"]`; sets `document.documentElement.lang` and `document.title` per language. 2. **Language state**: add `lang` to the persisted state object (`{a, step, screen, lang}`). On load: payload lang > saved state lang > `detectLang()`. **Share payload**: include `lang` in the `#p=` payload; old payloads without it → `en` (backward compatible). 3. **Header toggle**: a compact two-button pill group `EN | FR` in 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). 4. **index.html**: add `data-i18n` attributes 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 `` before `app.js`. Keep `` as first line in ``. 5. **app.js**: replace every user-facing literal with `t()` / formatters: - `STEPS` definitions: 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 `retire` marker, `runs out ≈`, `shortfall from ~`, G4 bar names + values. - Year table column headers; the collapsed `` label. - Vision card: `CAPTIONS` becomes `{ slug: {en, fr} }`; `renderVision` picks 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. 6. **style.css**: minor additions only if needed for the toggle + FR (e.g., `.lang-toggle` styles; allow `hyphens:auto` on 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 `` in each chart SVG has `getBBox().width` within the 640-wide viewBox (allow the horizontally-scrollable `.chartbox` on 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.py` to set `lang:'fr'` in localStorage before load). The `--sheet-h` live-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) 0. **Preflight**: confirm working tree clean at `65863c0`; run `node tests/engine.test.js` (36/36) and `node tests/ui.smoke.js` (SMOKE-OK); capture EN baseline screenshots at 390/320 (reuse `mobile_audit.py` → copy to `audit/bilingual-en-baseline/`). 1. **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. 2. **Wrap static HTML**: `data-i18n` attributes across index.html. 3. **Wrap app.js**: all dynamic strings via `t()`/formatters (steps, panel, insights, charts, table, vision captions, scenarios, errors, share/print strings). Vision `CAPTIONS` → `{slug:{en,fr}}`. 4. **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). Extend `tests/ui.smoke.js` to run its full assertion set in **both** languages (parameterize expected strings by lang; keep output ending `SMOKE-OK`). Engine tests untouched (36/36). 5. **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 troublebox `trouble@100.96.20.61:2222`, password candidates in `~/projects/*.py` lines containing `b1tvvt`): site 200 + marker. Save FR screenshots to `audit/bilingual-fr/`. 6. **Report + commit + push**: `BILINGUAL-REPORT.md` (what changed, glossary notes, test evidence, FR screenshots, known FR layout notes) + `audit/` evidence; commit + push to `krisf/retire-planner` main. ## Hard constraints - **Do not modify** `site/engine/engine.js` or `site/engine/canada.js` (presentation-only change). - Keep `` first line in ``; keep the privacy statement's meaning intact in both languages. - No frameworks, no build step, no CDN. `deploy.sh` rsyncs `site/` — `i18n.js` ships 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.js` green (parity/placeholders/formatters/FR render), `tests/ui.smoke.js` green 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`**