Bilingual EN/FR: i18n.js dict+t(), lang state+payload, header toggle, FR copy, Intl formatters; i18n.test.js + bilingual smoke; FR mobile audit evidence + BILINGUAL-REPORT.md

This commit is contained in:
krisf committed 2026-10-09 10:06:55 -04:00
1 parent 1b194b6f92
commit ea953ec23c
60 files changed
+2788 -241

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
# 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: <answers>, step, screen}`. Share links use `#p=<payload>` 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 `<script src="i18n.js"></script>` before `app.js`. Keep `<!-- retire-planner v1 -->` as first line in `<head>`.
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 `<summary>` 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 `<text>` 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 `<!-- 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.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`**