# BILINGUAL-REPORT.md — EN/FR for https://retire.krisforbes.ca Executed end-to-end per `BILINGUAL-PLAN.md` (Stages 0–6). Live at https://retire.krisforbes.ca/ — every user-facing string is bilingual, with a persisted `EN | FR` header toggle, locale-correct `Intl.NumberFormat` numbers, and `lang` carried in share links. **The engine (`site/engine/*.js`) is untouched.** ## What shipped | Piece | File | Notes | |---|---|---| | i18n scaffold | `site/i18n.js` (new, 558 lines) | `I18N` dict (EN + full FR mirror), `t(key, vars)` with dot-path lookup, fr→en→key fallback and `{name}` interpolation, `getLang/setLang/detectLang/applyI18n/wireToggle`, locale formatters `fmtMoney / fmtMoneyK / fmtInt / fmtPct / fmtMoney2`. | | Header toggle | `site/index.html`, `site/style.css` | `EN | FR` pill (`.lang-toggle`, `#lang-en/#lang-fr`, `aria-pressed`), 44px touch targets; mobile ≤860px keeps privacy pill hidden. | | App copy | `site/app.js` | 1:1 faithful port: all dynamic strings (14/17 steps, panel + chips + insight, chart titles/legends/annotations, verdict, gap cards, 11-col table, what-ifs, vision captions, validation errors, share/print/restart, disclaimer, privacy) via `t()`; **all geometry, ids, engine field names and control flow byte-identical to baseline.** | | Language state | `site/app.js` + `site/i18n.js` | LS `retire-planner-v1` = `{a, step, screen, lang}` (lang merged read-modify-write); share payload `#p={a,step,lang}`; load precedence **payload lang → saved lang → `navigator.language` fr\* → en**; legacy payloads (no lang) open EN. Toggle re-renders the current screen live (`__onLangChange`). | | FR typography | `site/i18n.js` `frNorm()` | U+2019 apostrophes, NBSP before `: ; ! ? %`, NBSP inside `« »`. | | Numbers | formatters in `site/i18n.js` | EN `$959,835` / `$523K` / `$1.05M` / `3.5%`; FR `959 835 $` / `523 k$` / `1,05 M$` / `3,5 %` (fr-CA Intl compact emits `523 k $`, so FR compact is hand-rolled to the plan's glossary form). All chart axis/legend/annotation text and table cells go through the formatters. | Glossary followed: REER, FERR, CELI, RPC, SV, SRG, ménage, conjoint, fractionnement du revenu de pension, récupération de la SV, RevenuQuébec.ca, glissez →, barèmes 2026, k$/M$ compacts. FR chart bar labels kept concise (e.g. g4 scenario names shortened) so they fit the viewBox. ## Tests (all from workspace root, all green pre-deploy) - `node tests/engine.test.js` → **RESULT: 36 pass, 0 fail** (engine untouched). - `node tests/i18n.test.js` → **I18N-OK** (48 checks): deep key-set parity en↔fr, no empty values, `{placeholder}` set equality for every key, every `t()` key used in app.js exists in both dicts, formatter exact outputs (`$959,835`/`959 835 $`, `$523K`/`523 k$`, `$1.05M`/`1,05 M$`, `5%`/`5 %`, `3.5%`/`3,5 %`, `−$32,962`/`−32 962 $`, `$1,507.65`/`1 507,65 $`), FR typography (U+2019, NBSP), `applyI18n` over the real `index.html` (FR title *Planificateur de retraite…*, h1, meta description, `documentElement.lang`, `aria-pressed`), lang persistence merge, load precedence (payload > saved > detect; legacy payload → en). - `node tests/ui.smoke.js` → **SMOKE-OK** (56 checks): full begin → province → 14 steps → results → what-if walk run **twice, EN and FR**, asserting locale strings (step titles, validation message, panel, verdict, chart titles, legend, table header, year summary, stamp `tax rates 2026 · CPP max $1,507.65` vs `barèmes 2026 · RPC max. 1 507,65 $`), plus `LS.lang === 'fr'` after the FR run. ## EN regression (baseline vs deployed, live site) `audit/bilingual-en-regression/` (fresh EN runs) vs `audit/bilingual-en-baseline/`: - Geometry identical: panel 374×218 top 618, chips 344×158, step body, btn-next, g1 svg 560×236, table `.tbl-wrap` clientW 326 (scrollW 518, horizontal-scroll pattern unchanged), vision 356×250 — at 390/360/320. - Pixel diff (thresholded, per screenshot): **0.9–4.1 %** of pixels — the differences are the header EN|FR toggle and the reformatted numerals (`$523K`, `2 %`-style compact/trim allowed by the plan's formatter spec). Header height 55→57 CSS px (toggle button); full-page doc height +6 px. No element moved; content layout below the header identical. - Desktop (≥861px): toggle is the only header addition; grid/content layout untouched (`desktop-results-en-top` diff evidence in `audit/bilingual-fr/`). ## FR mobile audit — 320/360/390 + desktop 1280 (`audit/bilingual-fr/`, 23 PNGs + geo.json) **98/98 programmatic checks pass** (`audit/bilingual_audit.py` → `FR-AUDIT-OK`): - **No horizontal document overflow** at any viewport: `max(documentElement, body).scrollWidth == innerWidth` (320/360/390/1280, intro, province grid, income input, slider, sheet-open, results top/charts/table). - **Chart text fits the viewBox**: every `` in every `#g1–#g4` SVG measured with `getBBox()`; **0 violations** on every device+screen (FR labels kept concise, e.g. g4 scenarios, `L'évolution de votre épargne`). - **Sheet/chips**: panel visible on every quiz step, chips font 11px, panel height 235px (33.5 vh at 320) and 291px (41.5 vh) open — live `--sheet-h` sync intact. - **CTA**: quiz *Suivant →* in viewport on every step (sticky foot), intro CTA rendered, share CTA on results (full-page shots). - **Table**: 11 headers in DOM, 9 visible at ≤560px (non-reg + GIS columns hidden by the pre-existing "Fix 5" mobile design), 13px header font, `.tbl-wrap` scrolls horizontally (clientW 256/296/326 < scrollW 549) — nothing clipped. - FR rendering proof: RPC/REER/CELI/retraite token counts per screen (5 intro, 2 quiz steps, 8–10 results); desktop EN control shot shows 0 FR tokens. ## Deploy + off-LAN verification - `bash scripts/deploy.sh` → **DEPLOY-OK**; served `index.html` marker `retire-planner v1` grep = 1; served `i18n.js` contains FR strings; `index.html` references `i18n.js`. - Off-LAN (`audit/offlan_check.py` via Midhurst troublebox, result in `audit/bilingual-geo-check.json`): **200 + marker + `lang-toggle` + `i18n.js` script + FR strings** (`OFFLAN-OK`, vantage trouble@100.96.20.61). ## FR notes / accepted deltas - Header brand uses ellipsis at 320px (*Planificateur de retraite…* with the EN *Retire Planner* kept short) so the toggle + restart stay on one row. - Legacy share links (payloads without `lang`) open in EN — deterministic, never garbled. - FR verdict/stamp strings use non-breaking narrow spaces per fr-CA Intl (`1 507,65 $`); tests normalize U+202F/U+00A0 for assertions. - EN micro-deltas mandated by the formatter spec: compact `$523K` (was `$523k`), trailing-zero trim on `pct2` (`2 %`-class output), `to {le}` in the verdict line (identical at the default life expectancy). ## Files changed `site/i18n.js` (new) · `site/app.js` · `site/index.html` · `site/style.css` · `tests/i18n.test.js` (new) · `tests/ui.smoke.js` · `audit/bilingual_audit.py` (new) · `audit/offlan_check.py` (new) · `audit/bilingual-en-baseline/` · `audit/bilingual-en-regression/` · `audit/bilingual-fr/` · `audit/bilingual-geo-check.json` · `BILINGUAL-PLAN.md` · `.gitignore`. `git diff --stat site/engine/` → empty: engine untouched, 36/36.