diff --git a/.gitignore b/.gitignore index 4931a7d..b69dbb8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .fetch/ -dsh-run.log +dsh-*.log *.pid node_modules/ site/vision/*.jpeg +.tmp/ diff --git a/BILINGUAL-PLAN.md b/BILINGUAL-PLAN.md new file mode 100644 index 0000000..a6e0f8b --- /dev/null +++ b/BILINGUAL-PLAN.md @@ -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: , 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`** diff --git a/BILINGUAL-REPORT.md b/BILINGUAL-REPORT.md new file mode 100644 index 0000000..6501f26 --- /dev/null +++ b/BILINGUAL-REPORT.md @@ -0,0 +1,89 @@ +# 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. diff --git a/audit/bilingual-en-baseline/iphone14-intro.png b/audit/bilingual-en-baseline/iphone14-intro.png new file mode 100644 index 0000000..b27aead Binary files /dev/null and b/audit/bilingual-en-baseline/iphone14-intro.png differ diff --git a/audit/bilingual-en-baseline/iphone14-quiz-income.png b/audit/bilingual-en-baseline/iphone14-quiz-income.png new file mode 100644 index 0000000..d527dc7 Binary files /dev/null and b/audit/bilingual-en-baseline/iphone14-quiz-income.png differ diff --git a/audit/bilingual-en-baseline/iphone14-quiz-province.png b/audit/bilingual-en-baseline/iphone14-quiz-province.png new file mode 100644 index 0000000..58b15a5 Binary files /dev/null and b/audit/bilingual-en-baseline/iphone14-quiz-province.png differ diff --git a/audit/bilingual-en-baseline/iphone14-quiz-sheet-open.png b/audit/bilingual-en-baseline/iphone14-quiz-sheet-open.png new file mode 100644 index 0000000..8431a25 Binary files /dev/null and b/audit/bilingual-en-baseline/iphone14-quiz-sheet-open.png differ diff --git a/audit/bilingual-en-baseline/iphone14-quiz-slider.png b/audit/bilingual-en-baseline/iphone14-quiz-slider.png new file mode 100644 index 0000000..1a87e17 Binary files /dev/null and b/audit/bilingual-en-baseline/iphone14-quiz-slider.png differ diff --git a/audit/bilingual-en-baseline/iphone14-results-charts.png b/audit/bilingual-en-baseline/iphone14-results-charts.png new file mode 100644 index 0000000..2fecece Binary files /dev/null and b/audit/bilingual-en-baseline/iphone14-results-charts.png differ diff --git a/audit/bilingual-en-baseline/iphone14-results-table.png b/audit/bilingual-en-baseline/iphone14-results-table.png new file mode 100644 index 0000000..ddff4e6 Binary files /dev/null and b/audit/bilingual-en-baseline/iphone14-results-table.png differ diff --git a/audit/bilingual-en-baseline/iphone14-results-top.png b/audit/bilingual-en-baseline/iphone14-results-top.png new file mode 100644 index 0000000..53bfab4 Binary files /dev/null and b/audit/bilingual-en-baseline/iphone14-results-top.png differ diff --git a/audit/bilingual-en-baseline/pixel-quiz-province.png b/audit/bilingual-en-baseline/pixel-quiz-province.png new file mode 100644 index 0000000..4e931ff Binary files /dev/null and b/audit/bilingual-en-baseline/pixel-quiz-province.png differ diff --git a/audit/bilingual-en-baseline/pixel-results-charts.png b/audit/bilingual-en-baseline/pixel-results-charts.png new file mode 100644 index 0000000..65196f5 Binary files /dev/null and b/audit/bilingual-en-baseline/pixel-results-charts.png differ diff --git a/audit/bilingual-en-baseline/se-quiz-income.png b/audit/bilingual-en-baseline/se-quiz-income.png new file mode 100644 index 0000000..893b053 Binary files /dev/null and b/audit/bilingual-en-baseline/se-quiz-income.png differ diff --git a/audit/bilingual-en-baseline/se-results-charts.png b/audit/bilingual-en-baseline/se-results-charts.png new file mode 100644 index 0000000..a263893 Binary files /dev/null and b/audit/bilingual-en-baseline/se-results-charts.png differ diff --git a/audit/bilingual-en-regression/iphone14-intro.png b/audit/bilingual-en-regression/iphone14-intro.png new file mode 100644 index 0000000..03df3c2 Binary files /dev/null and b/audit/bilingual-en-regression/iphone14-intro.png differ diff --git a/audit/bilingual-en-regression/iphone14-quiz-income.png b/audit/bilingual-en-regression/iphone14-quiz-income.png new file mode 100644 index 0000000..2083090 Binary files /dev/null and b/audit/bilingual-en-regression/iphone14-quiz-income.png differ diff --git a/audit/bilingual-en-regression/iphone14-quiz-province.png b/audit/bilingual-en-regression/iphone14-quiz-province.png new file mode 100644 index 0000000..82d81ea Binary files /dev/null and b/audit/bilingual-en-regression/iphone14-quiz-province.png differ diff --git a/audit/bilingual-en-regression/iphone14-quiz-sheet-open.png b/audit/bilingual-en-regression/iphone14-quiz-sheet-open.png new file mode 100644 index 0000000..fd204e7 Binary files /dev/null and b/audit/bilingual-en-regression/iphone14-quiz-sheet-open.png differ diff --git a/audit/bilingual-en-regression/iphone14-quiz-slider.png b/audit/bilingual-en-regression/iphone14-quiz-slider.png new file mode 100644 index 0000000..81add0f Binary files /dev/null and b/audit/bilingual-en-regression/iphone14-quiz-slider.png differ diff --git a/audit/bilingual-en-regression/iphone14-results-charts.png b/audit/bilingual-en-regression/iphone14-results-charts.png new file mode 100644 index 0000000..ec3daaf Binary files /dev/null and b/audit/bilingual-en-regression/iphone14-results-charts.png differ diff --git a/audit/bilingual-en-regression/iphone14-results-table.png b/audit/bilingual-en-regression/iphone14-results-table.png new file mode 100644 index 0000000..e16761e Binary files /dev/null and b/audit/bilingual-en-regression/iphone14-results-table.png differ diff --git a/audit/bilingual-en-regression/iphone14-results-top.png b/audit/bilingual-en-regression/iphone14-results-top.png new file mode 100644 index 0000000..92e12e4 Binary files /dev/null and b/audit/bilingual-en-regression/iphone14-results-top.png differ diff --git a/audit/bilingual-en-regression/pixel-quiz-province.png b/audit/bilingual-en-regression/pixel-quiz-province.png new file mode 100644 index 0000000..24f957f Binary files /dev/null and b/audit/bilingual-en-regression/pixel-quiz-province.png differ diff --git a/audit/bilingual-en-regression/pixel-results-charts.png b/audit/bilingual-en-regression/pixel-results-charts.png new file mode 100644 index 0000000..86d2142 Binary files /dev/null and b/audit/bilingual-en-regression/pixel-results-charts.png differ diff --git a/audit/bilingual-en-regression/se-quiz-income.png b/audit/bilingual-en-regression/se-quiz-income.png new file mode 100644 index 0000000..2c64302 Binary files /dev/null and b/audit/bilingual-en-regression/se-quiz-income.png differ diff --git a/audit/bilingual-en-regression/se-results-charts.png b/audit/bilingual-en-regression/se-results-charts.png new file mode 100644 index 0000000..8388808 Binary files /dev/null and b/audit/bilingual-en-regression/se-results-charts.png differ diff --git a/audit/bilingual-fr/desktop-intro-fr.png b/audit/bilingual-fr/desktop-intro-fr.png new file mode 100644 index 0000000..2d71765 Binary files /dev/null and b/audit/bilingual-fr/desktop-intro-fr.png differ diff --git a/audit/bilingual-fr/desktop-results-en-top.png b/audit/bilingual-fr/desktop-results-en-top.png new file mode 100644 index 0000000..a55e55e Binary files /dev/null and b/audit/bilingual-fr/desktop-results-en-top.png differ diff --git a/audit/bilingual-fr/desktop-results-fr-charts.png b/audit/bilingual-fr/desktop-results-fr-charts.png new file mode 100644 index 0000000..36ac531 Binary files /dev/null and b/audit/bilingual-fr/desktop-results-fr-charts.png differ diff --git a/audit/bilingual-fr/desktop-results-fr-top.png b/audit/bilingual-fr/desktop-results-fr-top.png new file mode 100644 index 0000000..dfcabe6 Binary files /dev/null and b/audit/bilingual-fr/desktop-results-fr-top.png differ diff --git a/audit/bilingual-fr/geo.json b/audit/bilingual-fr/geo.json new file mode 100644 index 0000000..6f09594 --- /dev/null +++ b/audit/bilingual-fr/geo.json @@ -0,0 +1,1196 @@ +{ + "geo": { + "iphone14:intro": { + "vw": 390, + "docSW": 390, + "bodySW": 390, + "docOverflow": false, + "g1first": null, + "svgTextOutsideViewBox": [], + "panelVisible": false, + "chipsVisible": false, + "chipFontPx": null, + "panelH": { + "h": 0, + "maxVh": 0 + }, + "btnNextVisible": false, + "btnShareVisible": false, + "btnShareOnPage": false, + "ctaOnPage": true, + "thCount": 0, + "thHidden": 0, + "thFontPx": null, + "tbl": { + "clientW": 0, + "scrollW": 0 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 5 + }, + "iphone14:quiz-province": { + "vw": 390, + "docSW": 390, + "bodySW": 390, + "docOverflow": false, + "g1first": null, + "svgTextOutsideViewBox": [], + "panelVisible": true, + "chipsVisible": true, + "chipFontPx": 11, + "panelH": { + "h": 218, + "maxVh": 25.8 + }, + "btnNextVisible": true, + "btnShareVisible": false, + "btnShareOnPage": false, + "ctaOnPage": false, + "thCount": 0, + "thHidden": 0, + "thFontPx": null, + "tbl": { + "clientW": 0, + "scrollW": 0 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 2 + }, + "iphone14:quiz-income": { + "vw": 390, + "docSW": 390, + "bodySW": 390, + "docOverflow": false, + "g1first": null, + "svgTextOutsideViewBox": [], + "panelVisible": true, + "chipsVisible": true, + "chipFontPx": 11, + "panelH": { + "h": 218, + "maxVh": 25.8 + }, + "btnNextVisible": true, + "btnShareVisible": false, + "btnShareOnPage": false, + "ctaOnPage": false, + "thCount": 0, + "thHidden": 0, + "thFontPx": null, + "tbl": { + "clientW": 0, + "scrollW": 0 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 2 + }, + "iphone14:quiz-slider": { + "vw": 390, + "docSW": 390, + "bodySW": 390, + "docOverflow": false, + "g1first": null, + "svgTextOutsideViewBox": [], + "panelVisible": true, + "chipsVisible": true, + "chipFontPx": 11, + "panelH": { + "h": 218, + "maxVh": 25.8 + }, + "btnNextVisible": true, + "btnShareVisible": false, + "btnShareOnPage": false, + "ctaOnPage": false, + "thCount": 0, + "thHidden": 0, + "thFontPx": null, + "tbl": { + "clientW": 0, + "scrollW": 0 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 2 + }, + "iphone14:quiz-sheet-open": { + "vw": 390, + "docSW": 390, + "bodySW": 390, + "docOverflow": false, + "g1first": null, + "svgTextOutsideViewBox": [], + "panelVisible": true, + "chipsVisible": true, + "chipFontPx": 11, + "panelH": { + "h": 274, + "maxVh": 32.4 + }, + "btnNextVisible": true, + "btnShareVisible": false, + "btnShareOnPage": false, + "ctaOnPage": false, + "thCount": 0, + "thHidden": 0, + "thFontPx": null, + "tbl": { + "clientW": 0, + "scrollW": 0 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 2 + }, + "iphone14:results-top": { + "vw": 390, + "docSW": 390, + "bodySW": 390, + "docOverflow": false, + "g1first": { + "fontSize": 13, + "renderedPx": 11.4 + }, + "svgTextOutsideViewBox": [], + "panelVisible": false, + "chipsVisible": false, + "chipFontPx": 11, + "panelH": { + "h": 0, + "maxVh": 0 + }, + "btnNextVisible": false, + "btnShareVisible": false, + "btnShareOnPage": true, + "ctaOnPage": false, + "thCount": 11, + "thHidden": 2, + "thFontPx": 13, + "tbl": { + "clientW": 326, + "scrollW": 549 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 8 + }, + "iphone14:results-charts": { + "vw": 390, + "docSW": 390, + "bodySW": 390, + "docOverflow": false, + "g1first": { + "fontSize": 13, + "renderedPx": 11.4 + }, + "svgTextOutsideViewBox": [], + "panelVisible": false, + "chipsVisible": false, + "chipFontPx": 11, + "panelH": { + "h": 0, + "maxVh": 0 + }, + "btnNextVisible": false, + "btnShareVisible": false, + "btnShareOnPage": true, + "ctaOnPage": false, + "thCount": 11, + "thHidden": 2, + "thFontPx": 13, + "tbl": { + "clientW": 326, + "scrollW": 549 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 8 + }, + "iphone14:results-table": { + "vw": 390, + "docSW": 390, + "bodySW": 390, + "docOverflow": false, + "g1first": { + "fontSize": 13, + "renderedPx": 11.4 + }, + "svgTextOutsideViewBox": [], + "panelVisible": false, + "chipsVisible": false, + "chipFontPx": 11, + "panelH": { + "h": 0, + "maxVh": 0 + }, + "btnNextVisible": false, + "btnShareVisible": false, + "btnShareOnPage": true, + "ctaOnPage": false, + "thCount": 11, + "thHidden": 2, + "thFontPx": 13, + "tbl": { + "clientW": 326, + "scrollW": 549 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 10 + }, + "pixel:intro": { + "vw": 360, + "docSW": 360, + "bodySW": 360, + "docOverflow": false, + "g1first": null, + "svgTextOutsideViewBox": [], + "panelVisible": false, + "chipsVisible": false, + "chipFontPx": null, + "panelH": { + "h": 0, + "maxVh": 0 + }, + "btnNextVisible": false, + "btnShareVisible": false, + "btnShareOnPage": false, + "ctaOnPage": true, + "thCount": 0, + "thHidden": 0, + "thFontPx": null, + "tbl": { + "clientW": 0, + "scrollW": 0 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 5 + }, + "pixel:quiz-province": { + "vw": 360, + "docSW": 360, + "bodySW": 360, + "docOverflow": false, + "g1first": null, + "svgTextOutsideViewBox": [], + "panelVisible": true, + "chipsVisible": true, + "chipFontPx": 11, + "panelH": { + "h": 235, + "maxVh": 29.3 + }, + "btnNextVisible": true, + "btnShareVisible": false, + "btnShareOnPage": false, + "ctaOnPage": false, + "thCount": 0, + "thHidden": 0, + "thFontPx": null, + "tbl": { + "clientW": 0, + "scrollW": 0 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 2 + }, + "pixel:results-charts": { + "vw": 360, + "docSW": 360, + "bodySW": 360, + "docOverflow": false, + "g1first": { + "fontSize": 13, + "renderedPx": 11.4 + }, + "svgTextOutsideViewBox": [], + "panelVisible": false, + "chipsVisible": false, + "chipFontPx": 11, + "panelH": { + "h": 0, + "maxVh": 0 + }, + "btnNextVisible": false, + "btnShareVisible": false, + "btnShareOnPage": true, + "ctaOnPage": false, + "thCount": 11, + "thHidden": 2, + "thFontPx": 13, + "tbl": { + "clientW": 296, + "scrollW": 549 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 8 + }, + "pixel:results-table": { + "vw": 360, + "docSW": 360, + "bodySW": 360, + "docOverflow": false, + "g1first": { + "fontSize": 13, + "renderedPx": 11.4 + }, + "svgTextOutsideViewBox": [], + "panelVisible": false, + "chipsVisible": false, + "chipFontPx": 11, + "panelH": { + "h": 0, + "maxVh": 0 + }, + "btnNextVisible": false, + "btnShareVisible": false, + "btnShareOnPage": true, + "ctaOnPage": false, + "thCount": 11, + "thHidden": 2, + "thFontPx": 13, + "tbl": { + "clientW": 296, + "scrollW": 549 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 10 + }, + "se:intro": { + "vw": 320, + "docSW": 320, + "bodySW": 320, + "docOverflow": false, + "g1first": null, + "svgTextOutsideViewBox": [], + "panelVisible": false, + "chipsVisible": false, + "chipFontPx": null, + "panelH": { + "h": 0, + "maxVh": 0 + }, + "btnNextVisible": false, + "btnShareVisible": false, + "btnShareOnPage": false, + "ctaOnPage": true, + "thCount": 0, + "thHidden": 0, + "thFontPx": null, + "tbl": { + "clientW": 0, + "scrollW": 0 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 5 + }, + "se:quiz-province": { + "vw": 320, + "docSW": 320, + "bodySW": 320, + "docOverflow": false, + "g1first": null, + "svgTextOutsideViewBox": [], + "panelVisible": true, + "chipsVisible": true, + "chipFontPx": 11, + "panelH": { + "h": 235, + "maxVh": 33.5 + }, + "btnNextVisible": true, + "btnShareVisible": false, + "btnShareOnPage": false, + "ctaOnPage": false, + "thCount": 0, + "thHidden": 0, + "thFontPx": null, + "tbl": { + "clientW": 0, + "scrollW": 0 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 2 + }, + "se:quiz-income": { + "vw": 320, + "docSW": 320, + "bodySW": 320, + "docOverflow": false, + "g1first": null, + "svgTextOutsideViewBox": [], + "panelVisible": true, + "chipsVisible": true, + "chipFontPx": 11, + "panelH": { + "h": 235, + "maxVh": 33.5 + }, + "btnNextVisible": true, + "btnShareVisible": false, + "btnShareOnPage": false, + "ctaOnPage": false, + "thCount": 0, + "thHidden": 0, + "thFontPx": null, + "tbl": { + "clientW": 0, + "scrollW": 0 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 2 + }, + "se:quiz-sheet-open": { + "vw": 320, + "docSW": 320, + "bodySW": 320, + "docOverflow": false, + "g1first": null, + "svgTextOutsideViewBox": [], + "panelVisible": true, + "chipsVisible": true, + "chipFontPx": 11, + "panelH": { + "h": 291, + "maxVh": 41.5 + }, + "btnNextVisible": true, + "btnShareVisible": false, + "btnShareOnPage": false, + "ctaOnPage": false, + "thCount": 0, + "thHidden": 0, + "thFontPx": null, + "tbl": { + "clientW": 0, + "scrollW": 0 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 2 + }, + "se:results-top": { + "vw": 320, + "docSW": 320, + "bodySW": 320, + "docOverflow": false, + "g1first": { + "fontSize": 13, + "renderedPx": 11.4 + }, + "svgTextOutsideViewBox": [], + "panelVisible": false, + "chipsVisible": false, + "chipFontPx": 11, + "panelH": { + "h": 0, + "maxVh": 0 + }, + "btnNextVisible": false, + "btnShareVisible": false, + "btnShareOnPage": true, + "ctaOnPage": false, + "thCount": 11, + "thHidden": 2, + "thFontPx": 13, + "tbl": { + "clientW": 256, + "scrollW": 549 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 8 + }, + "se:results-charts": { + "vw": 320, + "docSW": 320, + "bodySW": 320, + "docOverflow": false, + "g1first": { + "fontSize": 13, + "renderedPx": 11.4 + }, + "svgTextOutsideViewBox": [], + "panelVisible": false, + "chipsVisible": false, + "chipFontPx": 11, + "panelH": { + "h": 0, + "maxVh": 0 + }, + "btnNextVisible": false, + "btnShareVisible": false, + "btnShareOnPage": true, + "ctaOnPage": false, + "thCount": 11, + "thHidden": 2, + "thFontPx": 13, + "tbl": { + "clientW": 256, + "scrollW": 549 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 8 + }, + "se:results-table": { + "vw": 320, + "docSW": 320, + "bodySW": 320, + "docOverflow": false, + "g1first": { + "fontSize": 13, + "renderedPx": 11.4 + }, + "svgTextOutsideViewBox": [], + "panelVisible": false, + "chipsVisible": false, + "chipFontPx": 11, + "panelH": { + "h": 0, + "maxVh": 0 + }, + "btnNextVisible": false, + "btnShareVisible": false, + "btnShareOnPage": true, + "ctaOnPage": false, + "thCount": 11, + "thHidden": 2, + "thFontPx": 13, + "tbl": { + "clientW": 256, + "scrollW": 549 + }, + "hdr": 57, + "langBtnVisible": true, + "frTextSample": 10 + }, + "desktop:intro-fr": { + "vw": 1280, + "docSW": 1280, + "bodySW": 1280, + "docOverflow": false, + "g1first": null, + "svgTextOutsideViewBox": [], + "panelVisible": false, + "chipsVisible": false, + "chipFontPx": null, + "panelH": { + "h": 0, + "maxVh": 0 + }, + "btnNextVisible": false, + "btnShareVisible": false, + "btnShareOnPage": false, + "ctaOnPage": true, + "thCount": 0, + "thHidden": 0, + "thFontPx": null, + "tbl": { + "clientW": 0, + "scrollW": 0 + }, + "hdr": 67, + "langBtnVisible": true, + "frTextSample": 5 + }, + "desktop:results-fr-top": { + "vw": 1280, + "docSW": 1280, + "bodySW": 1280, + "docOverflow": false, + "g1first": { + "fontSize": 13, + "renderedPx": 20.4 + }, + "svgTextOutsideViewBox": [], + "panelVisible": false, + "chipsVisible": false, + "chipFontPx": 11, + "panelH": { + "h": 0, + "maxVh": 0 + }, + "btnNextVisible": false, + "btnShareVisible": false, + "btnShareOnPage": true, + "ctaOnPage": false, + "thCount": 11, + "thHidden": 0, + "thFontPx": 12.5, + "tbl": { + "clientW": 996, + "scrollW": 996 + }, + "hdr": 67, + "langBtnVisible": true, + "frTextSample": 8 + }, + "desktop:results-fr-charts": { + "vw": 1280, + "docSW": 1280, + "bodySW": 1280, + "docOverflow": false, + "g1first": { + "fontSize": 13, + "renderedPx": 20.4 + }, + "svgTextOutsideViewBox": [], + "panelVisible": false, + "chipsVisible": false, + "chipFontPx": 11, + "panelH": { + "h": 0, + "maxVh": 0 + }, + "btnNextVisible": false, + "btnShareVisible": false, + "btnShareOnPage": true, + "ctaOnPage": false, + "thCount": 11, + "thHidden": 0, + "thFontPx": 12.5, + "tbl": { + "clientW": 996, + "scrollW": 996 + }, + "hdr": 67, + "langBtnVisible": true, + "frTextSample": 8 + }, + "desktop:results-en-top": { + "vw": 1280, + "docSW": 1280, + "bodySW": 1280, + "docOverflow": false, + "g1first": { + "fontSize": 13, + "renderedPx": 20.4 + }, + "svgTextOutsideViewBox": [], + "panelVisible": false, + "chipsVisible": false, + "chipFontPx": 11, + "panelH": { + "h": 0, + "maxVh": 0 + }, + "btnNextVisible": false, + "btnShareVisible": false, + "btnShareOnPage": true, + "ctaOnPage": false, + "thCount": 11, + "thHidden": 0, + "thFontPx": 12.5, + "tbl": { + "clientW": 996, + "scrollW": 996 + }, + "hdr": 67, + "langBtnVisible": true, + "frTextSample": 0 + } + }, + "checks": [ + { + "name": "iphone14:intro no doc overflow", + "ok": true, + "extra": "390>390" + }, + { + "name": "iphone14:intro svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "iphone14:intro lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "iphone14:intro intro CTA rendered", + "ok": true, + "extra": "" + }, + { + "name": "iphone14:quiz-province no doc overflow", + "ok": true, + "extra": "390>390" + }, + { + "name": "iphone14:quiz-province svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "iphone14:quiz-province Next CTA visible", + "ok": true, + "extra": "" + }, + { + "name": "iphone14:quiz-province panel visible", + "ok": true, + "extra": "chip 11px panel {'h': 218, 'maxVh': 25.8}" + }, + { + "name": "iphone14:quiz-province lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "iphone14:quiz-income no doc overflow", + "ok": true, + "extra": "390>390" + }, + { + "name": "iphone14:quiz-income svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "iphone14:quiz-income Next CTA visible", + "ok": true, + "extra": "" + }, + { + "name": "iphone14:quiz-income panel visible", + "ok": true, + "extra": "chip 11px panel {'h': 218, 'maxVh': 25.8}" + }, + { + "name": "iphone14:quiz-income lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "iphone14:quiz-slider no doc overflow", + "ok": true, + "extra": "390>390" + }, + { + "name": "iphone14:quiz-slider svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "iphone14:quiz-slider Next CTA visible", + "ok": true, + "extra": "" + }, + { + "name": "iphone14:quiz-slider panel visible", + "ok": true, + "extra": "chip 11px panel {'h': 218, 'maxVh': 25.8}" + }, + { + "name": "iphone14:quiz-slider lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "iphone14:quiz-sheet-open no doc overflow", + "ok": true, + "extra": "390>390" + }, + { + "name": "iphone14:quiz-sheet-open svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "iphone14:quiz-sheet-open Next CTA visible", + "ok": true, + "extra": "" + }, + { + "name": "iphone14:quiz-sheet-open panel visible", + "ok": true, + "extra": "chip 11px panel {'h': 274, 'maxVh': 32.4}" + }, + { + "name": "iphone14:quiz-sheet-open lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "iphone14:results-top no doc overflow", + "ok": true, + "extra": "390>390" + }, + { + "name": "iphone14:results-top svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "iphone14:results-top share CTA rendered on page", + "ok": true, + "extra": "" + }, + { + "name": "iphone14:results-top lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "iphone14:results-charts no doc overflow", + "ok": true, + "extra": "390>390" + }, + { + "name": "iphone14:results-charts svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "iphone14:results-charts share CTA rendered on page", + "ok": true, + "extra": "" + }, + { + "name": "iphone14:results-charts lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "iphone14:results-table no doc overflow", + "ok": true, + "extra": "390>390" + }, + { + "name": "iphone14:results-table svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "iphone14:results-table table headers visible", + "ok": true, + "extra": "9/11 visible @13px (2 hidden by design)" + }, + { + "name": "iphone14:results-table table scrolls (not clipped)", + "ok": true, + "extra": "{'clientW': 326, 'scrollW': 549}" + }, + { + "name": "iphone14:results-table share CTA on page", + "ok": true, + "extra": "" + }, + { + "name": "iphone14:results-table lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "pixel:intro no doc overflow", + "ok": true, + "extra": "360>360" + }, + { + "name": "pixel:intro svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "pixel:intro lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "pixel:intro intro CTA rendered", + "ok": true, + "extra": "" + }, + { + "name": "pixel:quiz-province no doc overflow", + "ok": true, + "extra": "360>360" + }, + { + "name": "pixel:quiz-province svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "pixel:quiz-province Next CTA visible", + "ok": true, + "extra": "" + }, + { + "name": "pixel:quiz-province panel visible", + "ok": true, + "extra": "chip 11px panel {'h': 235, 'maxVh': 29.3}" + }, + { + "name": "pixel:quiz-province lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "pixel:results-charts no doc overflow", + "ok": true, + "extra": "360>360" + }, + { + "name": "pixel:results-charts svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "pixel:results-charts share CTA rendered on page", + "ok": true, + "extra": "" + }, + { + "name": "pixel:results-charts lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "pixel:results-table no doc overflow", + "ok": true, + "extra": "360>360" + }, + { + "name": "pixel:results-table svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "pixel:results-table table headers visible", + "ok": true, + "extra": "9/11 visible @13px (2 hidden by design)" + }, + { + "name": "pixel:results-table table scrolls (not clipped)", + "ok": true, + "extra": "{'clientW': 296, 'scrollW': 549}" + }, + { + "name": "pixel:results-table share CTA on page", + "ok": true, + "extra": "" + }, + { + "name": "pixel:results-table lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "se:intro no doc overflow", + "ok": true, + "extra": "320>320" + }, + { + "name": "se:intro svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "se:intro lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "se:intro intro CTA rendered", + "ok": true, + "extra": "" + }, + { + "name": "se:quiz-province no doc overflow", + "ok": true, + "extra": "320>320" + }, + { + "name": "se:quiz-province svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "se:quiz-province Next CTA visible", + "ok": true, + "extra": "" + }, + { + "name": "se:quiz-province panel visible", + "ok": true, + "extra": "chip 11px panel {'h': 235, 'maxVh': 33.5}" + }, + { + "name": "se:quiz-province lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "se:quiz-income no doc overflow", + "ok": true, + "extra": "320>320" + }, + { + "name": "se:quiz-income svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "se:quiz-income Next CTA visible", + "ok": true, + "extra": "" + }, + { + "name": "se:quiz-income panel visible", + "ok": true, + "extra": "chip 11px panel {'h': 235, 'maxVh': 33.5}" + }, + { + "name": "se:quiz-income lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "se:quiz-sheet-open no doc overflow", + "ok": true, + "extra": "320>320" + }, + { + "name": "se:quiz-sheet-open svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "se:quiz-sheet-open Next CTA visible", + "ok": true, + "extra": "" + }, + { + "name": "se:quiz-sheet-open panel visible", + "ok": true, + "extra": "chip 11px panel {'h': 291, 'maxVh': 41.5}" + }, + { + "name": "se:quiz-sheet-open lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "se:results-top no doc overflow", + "ok": true, + "extra": "320>320" + }, + { + "name": "se:results-top svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "se:results-top share CTA rendered on page", + "ok": true, + "extra": "" + }, + { + "name": "se:results-top lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "se:results-charts no doc overflow", + "ok": true, + "extra": "320>320" + }, + { + "name": "se:results-charts svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "se:results-charts share CTA rendered on page", + "ok": true, + "extra": "" + }, + { + "name": "se:results-charts lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "se:results-table no doc overflow", + "ok": true, + "extra": "320>320" + }, + { + "name": "se:results-table svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "se:results-table table headers visible", + "ok": true, + "extra": "9/11 visible @13px (2 hidden by design)" + }, + { + "name": "se:results-table table scrolls (not clipped)", + "ok": true, + "extra": "{'clientW': 256, 'scrollW': 549}" + }, + { + "name": "se:results-table share CTA on page", + "ok": true, + "extra": "" + }, + { + "name": "se:results-table lang toggle visible", + "ok": true, + "extra": "" + }, + { + "name": "desktop:intro-fr no doc overflow", + "ok": true, + "extra": "1280>1280" + }, + { + "name": "desktop:intro-fr svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "desktop:results-fr-top no doc overflow", + "ok": true, + "extra": "1280>1280" + }, + { + "name": "desktop:results-fr-top svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "desktop:results-fr-charts no doc overflow", + "ok": true, + "extra": "1280>1280" + }, + { + "name": "desktop:results-fr-charts svg text inside viewBox", + "ok": true, + "extra": "[]" + }, + { + "name": "desktop:results-en-top no doc overflow", + "ok": true, + "extra": "1280>1280" + }, + { + "name": "desktop:results-en-top svg text inside viewBox", + "ok": true, + "extra": "[]" + } + ] +} \ No newline at end of file diff --git a/audit/bilingual-fr/iphone14-intro.png b/audit/bilingual-fr/iphone14-intro.png new file mode 100644 index 0000000..efa392d Binary files /dev/null and b/audit/bilingual-fr/iphone14-intro.png differ diff --git a/audit/bilingual-fr/iphone14-quiz-income.png b/audit/bilingual-fr/iphone14-quiz-income.png new file mode 100644 index 0000000..8949ab8 Binary files /dev/null and b/audit/bilingual-fr/iphone14-quiz-income.png differ diff --git a/audit/bilingual-fr/iphone14-quiz-province.png b/audit/bilingual-fr/iphone14-quiz-province.png new file mode 100644 index 0000000..579f743 Binary files /dev/null and b/audit/bilingual-fr/iphone14-quiz-province.png differ diff --git a/audit/bilingual-fr/iphone14-quiz-sheet-open.png b/audit/bilingual-fr/iphone14-quiz-sheet-open.png new file mode 100644 index 0000000..f4c45d5 Binary files /dev/null and b/audit/bilingual-fr/iphone14-quiz-sheet-open.png differ diff --git a/audit/bilingual-fr/iphone14-quiz-slider.png b/audit/bilingual-fr/iphone14-quiz-slider.png new file mode 100644 index 0000000..19de4dc Binary files /dev/null and b/audit/bilingual-fr/iphone14-quiz-slider.png differ diff --git a/audit/bilingual-fr/iphone14-results-charts.png b/audit/bilingual-fr/iphone14-results-charts.png new file mode 100644 index 0000000..79ba22b Binary files /dev/null and b/audit/bilingual-fr/iphone14-results-charts.png differ diff --git a/audit/bilingual-fr/iphone14-results-table.png b/audit/bilingual-fr/iphone14-results-table.png new file mode 100644 index 0000000..afa478e Binary files /dev/null and b/audit/bilingual-fr/iphone14-results-table.png differ diff --git a/audit/bilingual-fr/iphone14-results-top.png b/audit/bilingual-fr/iphone14-results-top.png new file mode 100644 index 0000000..d4c6607 Binary files /dev/null and b/audit/bilingual-fr/iphone14-results-top.png differ diff --git a/audit/bilingual-fr/pixel-intro.png b/audit/bilingual-fr/pixel-intro.png new file mode 100644 index 0000000..4b2ea3e Binary files /dev/null and b/audit/bilingual-fr/pixel-intro.png differ diff --git a/audit/bilingual-fr/pixel-quiz-province.png b/audit/bilingual-fr/pixel-quiz-province.png new file mode 100644 index 0000000..c71f2ae Binary files /dev/null and b/audit/bilingual-fr/pixel-quiz-province.png differ diff --git a/audit/bilingual-fr/pixel-results-charts.png b/audit/bilingual-fr/pixel-results-charts.png new file mode 100644 index 0000000..e777bf5 Binary files /dev/null and b/audit/bilingual-fr/pixel-results-charts.png differ diff --git a/audit/bilingual-fr/pixel-results-table.png b/audit/bilingual-fr/pixel-results-table.png new file mode 100644 index 0000000..411b489 Binary files /dev/null and b/audit/bilingual-fr/pixel-results-table.png differ diff --git a/audit/bilingual-fr/se-intro.png b/audit/bilingual-fr/se-intro.png new file mode 100644 index 0000000..1a01488 Binary files /dev/null and b/audit/bilingual-fr/se-intro.png differ diff --git a/audit/bilingual-fr/se-quiz-income.png b/audit/bilingual-fr/se-quiz-income.png new file mode 100644 index 0000000..dbb361a Binary files /dev/null and b/audit/bilingual-fr/se-quiz-income.png differ diff --git a/audit/bilingual-fr/se-quiz-province.png b/audit/bilingual-fr/se-quiz-province.png new file mode 100644 index 0000000..c997d1d Binary files /dev/null and b/audit/bilingual-fr/se-quiz-province.png differ diff --git a/audit/bilingual-fr/se-quiz-sheet-open.png b/audit/bilingual-fr/se-quiz-sheet-open.png new file mode 100644 index 0000000..d1b9c42 Binary files /dev/null and b/audit/bilingual-fr/se-quiz-sheet-open.png differ diff --git a/audit/bilingual-fr/se-results-charts.png b/audit/bilingual-fr/se-results-charts.png new file mode 100644 index 0000000..176191a Binary files /dev/null and b/audit/bilingual-fr/se-results-charts.png differ diff --git a/audit/bilingual-fr/se-results-table.png b/audit/bilingual-fr/se-results-table.png new file mode 100644 index 0000000..c81d886 Binary files /dev/null and b/audit/bilingual-fr/se-results-table.png differ diff --git a/audit/bilingual-fr/se-results-top.png b/audit/bilingual-fr/se-results-top.png new file mode 100644 index 0000000..d907eeb Binary files /dev/null and b/audit/bilingual-fr/se-results-top.png differ diff --git a/audit/bilingual-geo-check.json b/audit/bilingual-geo-check.json new file mode 100644 index 0000000..8f73e1e --- /dev/null +++ b/audit/bilingual-geo-check.json @@ -0,0 +1,11 @@ +{ + "http_code": "200", + "has_marker": true, + "has_lang_toggle": true, + "has_i18n_script": true, + "i18n_js_fr_strings": true, + "i18n_js_size": 28788, + "checked_at": "2026-10-09T14:01:51Z", + "vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)", + "all_ok": true +} \ No newline at end of file diff --git a/audit/bilingual_audit.py b/audit/bilingual_audit.py new file mode 100644 index 0000000..8d76006 --- /dev/null +++ b/audit/bilingual_audit.py @@ -0,0 +1,193 @@ +"""Bilingual FR mobile audit for https://retire.krisforbes.ca — FR screens at 320/360/390 +plus desktop 1280 (FR) and EN desktop (regression evidence). Sets localStorage +{a, step, screen, lang:'fr'}, captures screenshots and programmatic geometry: +- document horizontal overflow (scrollWidth vs innerWidth) +- every chart getBBox() inside the 640x270 viewBox +- panel/chips/CTA visibility, table header cells visible + font size +Output: audit/bilingual-fr/*.png + audit/bilingual-fr/geo.json + FR-AUDIT-OK/FAIL. +""" +import json, os, sys +from playwright.sync_api import sync_playwright + +OUT = sys.argv[1] if len(sys.argv) > 1 else 'audit/bilingual-fr' +os.makedirs(OUT, exist_ok=True) +CHROME = '/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome' +BASE = 'https://retire.krisforbes.ca/' +LS = 'retire-planner-v1' + +ANSWERS = { + "province": "ON", "age": 45, "household": "single", + "partner": {"age": 45, "income": 0, "rrsp": 0, "tfsa": 0, "nonreg": 0, "monthlyContribution": 0}, + "income": 85000, "rrsp": 250000, "tfsa": 60000, + "nonreg": 40000, "monthlyContribution": 800, "employerMatchMonthly": 0, + "retireAge": 65, "spendingAnnual": 60000, + "cppStartAge": 65, "oasStartAge": 65, + "pensionAnnual": 12000, "rentalAnnual": 0, "otherAnnual": 0, + "realReturnPre": 3.5, "realReturnPost": 2.5, "inflation": 2.0, "lifeExp": 95, + "interests": ["dock", "travel"] +} + +DEVICES = [ + ('iphone14', 390, 844, 3.0, 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'), + ('pixel', 360, 800, 2.75, 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36'), + ('se', 320, 700, 2.0, 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1'), + ('desktop', 1280, 900, 1.0, 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'), +] + +SHOTS = [ + ('iphone14', 'intro', 'intro', 0, None), + ('iphone14', 'quiz-province', 'quiz', 0, None), + ('iphone14', 'quiz-income', 'quiz', 3, None), + ('iphone14', 'quiz-slider', 'quiz', 7, None), + ('iphone14', 'quiz-sheet-open', 'quiz', 3, 'open_sheet'), + ('iphone14', 'results-top', 'results', 0, None), + ('iphone14', 'results-charts', 'results', 0, 'charts'), + ('iphone14', 'results-table', 'results', 0, 'table'), + ('pixel', 'intro', 'intro', 0, None), + ('pixel', 'quiz-province', 'quiz', 0, None), + ('pixel', 'results-charts', 'results', 0, 'charts'), + ('pixel', 'results-table', 'results', 0, 'table'), + ('se', 'intro', 'intro', 0, None), + ('se', 'quiz-province', 'quiz', 0, None), + ('se', 'quiz-income', 'quiz', 3, None), + ('se', 'quiz-sheet-open', 'quiz', 3, 'open_sheet'), + ('se', 'results-top', 'results', 0, None), + ('se', 'results-charts', 'results', 0, 'charts'), + ('se', 'results-table', 'results', 0, 'table'), + ('desktop', 'intro-fr', 'intro', 0, None), + ('desktop', 'results-fr-top', 'results', 0, None), + ('desktop', 'results-fr-charts', 'results', 0, 'charts'), + ('desktop', 'results-en-top', 'results', 0, 'en_lang'), +] + +GEO_JS = """() => { + const vw = window.innerWidth, vh = window.innerHeight; + const docSW = document.documentElement.scrollWidth, bodySW = document.body.scrollWidth; + const vis = (e) => { if (!e) return false; const b = e.getBoundingClientRect(); + return b.width > 0 && b.height > 0 && b.bottom > 0 && b.top < vh; }; + const g = s => document.querySelector(s); + // every chart inside the viewBox (0..640 x 0..270) + const svgProblems = []; + let g1first = null; + document.querySelectorAll('#g1 svg, #g2 svg, #g3 svg, #g4 svg').forEach(svg => { + const rect = svg.getBoundingClientRect(); + const scale = rect.width / 640; + svg.querySelectorAll('text').forEach(tx => { + const b = tx.getBBox(); + if (b.x < -0.5 || b.y < -0.5 || b.x + b.width > 640.5 || b.y + b.height > 270.5) { + svgProblems.push({id: svg.closest('[id]').id, txt: tx.textContent.slice(0, 30), x: Math.round(b.x), w: Math.round(b.width)}); + } + const id = svg.closest('[id]').id; + if (id === 'g1' && !g1first) { + g1first = {fontSize: parseFloat(tx.getAttribute('font-size') || 11), renderedPx: +(rect.width / 640 * (parseFloat(tx.getAttribute('font-size') || 11))).toFixed(1)}; + } + }); + }); + const ths = [...document.querySelectorAll('#yr-table th')]; + return { + vw, docSW, bodySW, + docOverflow: Math.max(docSW, bodySW) > vw + 1, + g1first, svgTextOutsideViewBox: svgProblems, + panelVisible: vis(g('.panel')), chipsVisible: vis(g('#panel-chips')), + chipFontPx: (() => { const k = g('#panel-chips .chip .k'); return k ? parseFloat(getComputedStyle(k).fontSize) : null; })(), + panelH: (() => { const p = g('.panel'); return p ? {h: Math.round(p.getBoundingClientRect().height), maxVh: +(p.getBoundingClientRect().height / vh * 100).toFixed(1)} : null; })(), + btnNextVisible: vis(g('#btn-next')), + btnShareVisible: vis(g('#btn-share')), + btnShareOnPage: (() => { const e = g('#btn-share'); if (!e) return false; const b = e.getBoundingClientRect(); return b.width > 0 && b.height > 0; })(), + ctaOnPage: (() => { const e = g('#btn-begin'); if (!e) return false; const b = e.getBoundingClientRect(); return b.width > 0 && b.height > 0; })(), + thCount: ths.length, + thHidden: ths.filter(t => getComputedStyle(t).display === 'none' || !t.offsetWidth).length, + thFontPx: ths.length ? parseFloat(getComputedStyle(ths[0]).fontSize) : null, + tbl: (() => { const t = g('.tbl-wrap'); return t ? {clientW: t.clientWidth, scrollW: t.scrollWidth} : null; })(), + hdr: (() => { const h = g('.hdr'); return h ? Math.round(h.getBoundingClientRect().height) : null; })(), + langBtnVisible: vis(g('#lang-fr')), + frTextSample: (document.body.innerText.match(/ RPC|REER|CELI|retraite/g) || []).length + }; +}""" + + +def set_state(page, screen, step, lang): + page.evaluate("""([ls, a, screen, step, lang]) => { + localStorage.setItem(ls, JSON.stringify({a: a, step: step, screen: screen, lang: lang})); + }""", [LS, ANSWERS, screen, step, lang]) + + +results = {} +with sync_playwright() as pw: + browser = pw.chromium.launch(executable_path=CHROME, headless=True, + args=['--no-sandbox', '--disable-dev-shm-usage']) + for dev, w, h, dsf, ua in DEVICES: + ctx = browser.new_context(viewport={'width': w, 'height': h}, + device_scale_factor=dsf, is_mobile=(dev != 'desktop'), + has_touch=(dev != 'desktop'), user_agent=ua) + page = ctx.new_page() + page.goto(BASE, wait_until='networkidle') + for d, tag, screen, step, action in [s for s in SHOTS if s[0] == dev]: + lang = 'en' if action == 'en_lang' else 'fr' + set_state(page, screen, step, lang) + page.reload(wait_until='networkidle') + page.wait_for_timeout(700) + if action == 'open_sheet': + try: + page.click('.panel-h', timeout=3000) + page.wait_for_timeout(400) + except Exception as e: + print('sheet open failed:', tag, e) + elif action == 'charts': + el = page.locator('#g1') + if el.count(): + el.scroll_into_view_if_needed() + page.wait_for_timeout(900) # let G4 async render + elif action == 'table': + page.evaluate("() => { const d = document.querySelector('details.yearly'); if (d) d.open = true; }") + el = page.locator('#yr-table') + if el.count(): + el.scroll_into_view_if_needed() + page.wait_for_timeout(300) + page.wait_for_timeout(300) + path = os.path.join(OUT, f'{dev}-{tag}.png') + page.screenshot(path=path, full_page=(action is None and dev != 'desktop')) + print('saved', path) + # geometry per device on the FR results screen (full app rendered) + key = f'{dev}:{tag}' + results[key] = page.evaluate(GEO_JS) + print(key, json.dumps({k: results[key][k] for k in + ('docOverflow', 'docSW', 'vw', 'thCount', 'thHidden', 'thFontPx')})) + ctx.close() + browser.close() + +# ---- evaluate FR constraints ---- +checks = [] +def chk(name, cond, extra=''): + checks.append((name, bool(cond), extra)) + print(('PASS ' if cond else 'FAIL ') + name + (' [' + str(extra)[:90] + ']' if extra != '' else '')) + +for key, g in results.items(): + dev = key.split(':')[0] + chk(key + ' no doc overflow', not g['docOverflow'], f"{g['docSW']}>{g['vw']}") + chk(key + ' svg text inside viewBox', len(g['svgTextOutsideViewBox']) == 0, g['svgTextOutsideViewBox'][:2]) + if dev == 'desktop': + continue + if 'results-table' in key: + # baseline Fix 5: ≤560px viewport hides 2 of 11 columns by design (non-reg, GIS) + exp_hidden = 2 if g['vw'] <= 560 else 0 + chk(key + ' table headers visible', g['thCount'] == 11 and g['thHidden'] == exp_hidden, + f"{g['thCount']-g['thHidden']}/{g['thCount']} visible @{g['thFontPx']}px ({exp_hidden} hidden by design)") + chk(key + ' table scrolls (not clipped)', g['tbl'] and g['tbl']['scrollW'] > g['vw'], g['tbl']) + chk(key + ' share CTA on page', g['btnShareOnPage']) + if 'results' in key and 'results-table' not in key: + chk(key + ' share CTA rendered on page', g['btnShareOnPage']) + if 'quiz' in key: + chk(key + ' Next CTA visible', g['btnNextVisible']) + chk(key + ' panel visible', g['panelVisible'] and g['chipsVisible'], f"chip {g['chipFontPx']}px panel {g['panelH']}") + chk(key + ' lang toggle visible', g['langBtnVisible']) + if 'intro' in key: + chk(key + ' intro CTA rendered', g['ctaOnPage']) + +ok = all(c[1] for c in checks) +with open(os.path.join(OUT, 'geo.json'), 'w') as f: + json.dump({'geo': results, 'checks': [{'name': n, 'ok': o, 'extra': str(e)} for n, o, e in checks]}, + f, indent=2, ensure_ascii=False) +print('checks:', sum(1 for c in checks if c[1]), '/', len(checks)) +print('FR-AUDIT-OK' if ok else 'FR-AUDIT-FAIL') +sys.exit(0 if ok else 1) diff --git a/audit/offlan_check.py b/audit/offlan_check.py new file mode 100644 index 0000000..ec9e842 --- /dev/null +++ b/audit/offlan_check.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Off-LAN reachability check for https://retire.krisforbes.ca/ via the Midhurst troublebox. +SSH to trouble@100.96.20.61:2222, curl the site from there (outside the LAN), verify 200 + +served bilingual markers. Writes audit/bilingual-geo-check.json, prints OFFLAN-OK.""" +import json, sys, time +import paramiko + +CANDS = ["b1tvvt07", "ubnt", "admin", "password", "b1tvvt07!", "1234", "root", "glinet"] +URL = "https://retire.krisforbes.ca/" + +def main(): + conn = None + for pw in CANDS: + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + c.connect("100.96.20.61", port=2222, username="trouble", password=pw, + timeout=25, banner_timeout=25, auth_timeout=25, + allow_agent=False, look_for_keys=False) + conn = c + break + except Exception: + c.close() + if conn is None: + print("OFFLAN-FAIL: no auth to troublebox") + sys.exit(1) + + def run(cmd): + _, out, _ = conn.exec_command(cmd, timeout=60) + return out.read().decode("utf-8", "replace").strip() + + code = run(f"curl -s -o /dev/null -w '%{{http_code}}' {URL} --max-time 30") + html = run(f"curl -s {URL} --max-time 30 | head -c 200000") + i18n = run(f"curl -s {URL}i18n.js --max-time 30 | head -c 200000") + conn.close() + + checks = { + "http_code": code, + "has_marker": "retire-planner v1" in html, + "has_lang_toggle": "lang-toggle" in html, + "has_i18n_script": "i18n.js" in html, + "i18n_js_fr_strings": ("Planificateur" in i18n) and ("D\u00e9penses" in i18n or "RPC" in i18n), + "i18n_js_size": len(i18n), + "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)", + } + checks["all_ok"] = (code == "200" and checks["has_marker"] and checks["has_lang_toggle"] + and checks["has_i18n_script"] and checks["i18n_js_fr_strings"]) + with open("audit/bilingual-geo-check.json", "w") as f: + json.dump(checks, f, indent=2, ensure_ascii=False) + print(json.dumps(checks, indent=2, ensure_ascii=False)) + print("OFFLAN-OK" if checks["all_ok"] else "OFFLAN-FAIL") + sys.exit(0 if checks["all_ok"] else 1) + +if __name__ == "__main__": + main() diff --git a/site/app.js b/site/app.js index 93a002a..2de2f2d 100644 --- a/site/app.js +++ b/site/app.js @@ -1,29 +1,41 @@ /* Retire Planner UI — vanilla, no build, no network except same-origin vision manifest. - All computation via window.RetireEngine. Answers persist in localStorage + URL hash. */ + All computation via window.RetireEngine. Answers persist in localStorage + URL hash. + All user-facing strings via i18n.js (loaded first): t() + Intl formatters + (fmtMoney/fmtMoneyK/fmtMoney2/fmtInt/fmtPct) so every number follows the active locale. */ 'use strict'; (function () { var E = window.RetireEngine, C = window.RetireCanada; var LS = 'retire-planner-v1'; var $ = function (s) { return document.querySelector(s); }; + /* locale-aware aliases (i18n.js globals) — chart/table code below stays untouched in shape */ + var money = fmtMoney, k = fmtMoneyK, pct = fmtPct; - var PROVINCES = [['ON', 'Ontario'], ['BC', 'British Columbia'], ['AB', 'Alberta'], ['SK', 'Saskatchewan'], - ['MB', 'Manitoba'], ['QC', 'Quebec'], ['NS', 'Nova Scotia'], ['NB', 'New Brunswick'], - ['NL', 'Newfoundland & Labrador'], ['PE', 'PEI'], ['YT', 'Yukon'], ['NT', 'Northwest Territories'], ['NU', 'Nunavut']]; + var PROVINCES = [['ON', 'prov.ON'], ['BC', 'prov.BC'], ['AB', 'prov.AB'], ['SK', 'prov.SK'], + ['MB', 'prov.MB'], ['QC', 'prov.QC'], ['NS', 'prov.NS'], ['NB', 'prov.NB'], + ['NL', 'prov.NL'], ['PE', 'prov.PE'], ['YT', 'prov.YT'], ['NT', 'prov.NT'], ['NU', 'prov.NU']]; var INTERESTS = [ - ['rockies', '🏔 Mountains'], ['maritimes', '🌊 Ocean'], ['toronto', '🏙 Big city'], - ['dock', '🛶 Lake / dock'], ['muskoka', '🌲 Cottage country'], ['prairies', '🌾 Open sky'], - ['winter', '⛷ Snow / ski'], ['workshop', '🪵 Workshop'], ['garden', '🌺 Garden'], - ['travel', '✈️ Travel'], ['quebec', '🇫🇷 Québec'] + ['rockies', 'int.rockies'], ['maritimes', 'int.maritimes'], ['toronto', 'int.toronto'], + ['dock', 'int.dock'], ['muskoka', 'int.muskoka'], ['prairies', 'int.prairies'], + ['winter', 'int.winter'], ['workshop', 'int.workshop'], ['garden', 'int.garden'], + ['travel', 'int.travel'], ['quebec', 'int.quebec'] ]; var PROV_SLUGS = { ON: ['toronto', 'muskoka', 'garden'], QC: ['quebec'], BC: ['vancouver'], AB: ['rockies', 'prairies'], MB: ['prairies'], SK: ['prairies'], NB: ['maritimes', 'dock'], NS: ['maritimes', 'dock'], PE: ['maritimes'], NL: ['maritimes'], YT: ['winter', 'rockies'], NT: ['winter'], NU: ['winter'] }; + /* captions per slug per language (hero … dock) */ var CAPTIONS = { - hero: 'The good life — paid for by the plan on this page.', muskoka: 'Cottage country, mortgage-free.', - rockies: 'Lake Louise at 68, on your own schedule.', maritimes: 'Lobster season, every season.', - prairies: 'Wide skies and a paid-off acreage.', quebec: 'Terrasses, fêtes et liberté financière.', - vancouver: 'Ocean sunsets from a paid-for deck.', toronto: 'City life, funded — not scraped.', - garden: 'Mornings that start with tomatoes.', travel: 'Long-haul flights, fully deductible… just kidding, paid in cash.', - workshop: 'A shop full of projects and zero alarms.', winter: 'Fresh tracks on a Tuesday.', dock: 'First coffee on your own dock.' + hero: { en: 'The good life — paid for by the plan on this page.', fr: 'La belle vie — payée par le plan sur cette page.' }, + muskoka: { en: 'Cottage country, mortgage-free.', fr: 'Le pays des chalets, hypothèque payée.' }, + rockies: { en: 'Lake Louise at 68, on your own schedule.', fr: 'Le lac Louise à 68 ans, à votre rythme.' }, + maritimes: { en: 'Lobster season, every season.', fr: 'La saison du homard, toutes les saisons.' }, + prairies: { en: 'Wide skies and a paid-off acreage.', fr: 'De grands espaces et une terre payée.' }, + quebec: { en: 'Terrasses, fêtes et liberté financière.', fr: 'Terrasses, fêtes et liberté financière.' }, + vancouver: { en: 'Ocean sunsets from a paid-for deck.', fr: 'Des couchers de soleil sur l’océan depuis une terrasse payée.' }, + toronto: { en: 'City life, funded — not scraped.', fr: 'La vie de ville, financée — pas au rabais.' }, + garden: { en: 'Mornings that start with tomatoes.', fr: 'Des matins qui commencent par des tomates.' }, + travel: { en: 'Long-haul flights, fully deductible… just kidding, paid in cash.', fr: 'Des vols long-courriers, entièrement déductibles… je plaisante, payés comptant.' }, + workshop: { en: 'A shop full of projects and zero alarms.', fr: 'Un atelier plein de projets et zéro réveil.' }, + winter: { en: 'Fresh tracks on a Tuesday.', fr: 'De la neige fraîche un mardi.' }, + dock: { en: 'First coffee on your own dock.', fr: 'Le premier café sur votre propre quai.' } }; var GRADS = ['linear-gradient(135deg,#1e3a8a,#0ea5e9)', 'linear-gradient(135deg,#7c2d12,#f59e0b)', 'linear-gradient(135deg,#14532d,#84cc16)', 'linear-gradient(135deg,#4c1d95,#ec4899)', @@ -50,13 +62,13 @@ var m = location.hash.match(/[#&]p=([^&]+)/); if (m) { try { raw = decodeURIComponent(escape(atob(m[1]))); } catch (e) { raw = null; } } if (!raw) { try { raw = localStorage.getItem(LS); } catch (e) { raw = null; } } - if (raw) { try { var o = JSON.parse(raw); Object.assign(state.a, o.a || o); state.step = o.step | 0; if (o.screen) state.screen = o.screen; else if (m) state.screen = 'results'; } catch (e) { /* fresh */ } } + if (raw) { try { var o = JSON.parse(raw); var b = Object.assign({}, o.a || o); delete b.lang; Object.assign(state.a, b); state.step = o.step | 0; if (o.screen) state.screen = o.screen; else if (m) state.screen = 'results'; } catch (e) { /* fresh */ } } })(); function save() { - var t = { a: state.a, step: state.step, screen: state.screen }; - try { localStorage.setItem(LS, JSON.stringify(t)); } catch (e) { } + var t2 = { a: state.a, step: state.step, screen: state.screen, lang: getLang() }; + try { localStorage.setItem(LS, JSON.stringify(t2)); } catch (e) { } } - function encState() { return btoa(unescape(encodeURIComponent(JSON.stringify({ a: state.a, step: 0 })))); } + function encState() { return btoa(unescape(encodeURIComponent(JSON.stringify({ a: state.a, step: 0, lang: getLang() })))); } function inputs() { var a = state.a, w = state.wi; @@ -80,117 +92,109 @@ return p; } - /* ---------- formatting ---------- */ - function money(v) { v = Math.round(v); return (v < 0 ? '−$' : '$') + Math.abs(v).toLocaleString('en-CA'); } - function k(v) { - var s = v < 0 ? '−' : ''; v = Math.abs(v); - if (v >= 1e6) return s + '$' + (v / 1e6).toFixed(v >= 1e7 ? 1 : 2) + 'M'; - if (v >= 1000) return s + '$' + Math.round(v / 1000) + 'k'; - return s + '$' + Math.round(v); - } - function pct(v) { return (v * 100).toFixed(0) + '%'; } + /* ---------- formatting (i18n.js: Intl.NumberFormat, locale-aware) ---------- */ + function pct2(v) { return fmtPct(v / 100, 2); } /* slider values arrive as plain numbers: 3.5 → 3.5% */ /* ---------- steps ---------- */ function couple() { return state.a.household === 'couple'; } var STEPS = [ { - key: 'province', title: 'Where do you live?', hint: 'Taxes, and your retirement view.', + key: 'province', titleK: 'q.province.title', hintK: 'q.province.hint', type: 'grid', opts: PROVINCES, get: function () { return state.a.province; }, set: function (v) { state.a.province = v; } }, { - key: 'age', title: 'Your age', hint: 'Years.', type: 'number', min: 18, max: 74, + key: 'age', titleK: 'q.age.title', hintK: 'q.age.hint', type: 'number', min: 18, max: 74, get: function () { return state.a.age; }, set: function (v) { state.a.age = +v; } }, { - key: 'household', title: 'Household', hint: 'Couple means joint taxes, pension splitting and household GIS.', - type: 'opts', opts: [['single', 'Single'], ['couple', 'Couple']], + key: 'household', titleK: 'q.household.title', hintK: 'q.household.hint', + type: 'opts', opts: [['single', 'q.household.single'], ['couple', 'q.household.couple']], get: function () { return state.a.household; }, set: function (v) { state.a.household = v; } }, { - key: 'p_age', show: couple, title: 'Partner’s age', type: 'number', min: 18, max: 90, + key: 'p_age', show: couple, titleK: 'q.p_age.title', type: 'number', min: 18, max: 90, get: function () { return state.a.partner.age; }, set: function (v) { state.a.partner.age = +v; } }, { - key: 'p_income', show: couple, title: 'Partner’s annual employment income', hint: '$/yr before tax. 0 if not working.', + key: 'p_income', show: couple, titleK: 'q.p_income.title', hintK: 'q.p_income.hint', type: 'number', min: 0, max: 1e6, money: true, get: function () { return state.a.partner.income; }, set: function (v) { state.a.partner.income = +v; } }, { - key: 'p_savings', show: couple, title: 'Partner’s savings', hint: 'Current balances, today’s dollars.', + key: 'p_savings', show: couple, titleK: 'q.p_savings.title', hintK: 'q.p_savings.hint', type: 'fields', fields: [ - { label: 'RRSP / RRIF', key: 'rrsp' }, { label: 'TFSA', key: 'tfsa' }, { label: 'Non-registered', key: 'nonreg' } + { labelK: 'q.p_savings.rrsp', key: 'rrsp' }, { labelK: 'q.p_savings.tfsa', key: 'tfsa' }, { labelK: 'q.p_savings.nonreg', key: 'nonreg' } ], get: function (f) { return state.a.partner[f]; }, set: function (f, v) { state.a.partner[f] = +v; } }, { - key: 'income', title: 'Your annual employment income', hint: '$/yr. Used to estimate your CPP history.', + key: 'income', titleK: 'q.income.title', hintK: 'q.income.hint', type: 'number', min: 0, max: 1e6, money: true, get: function () { return state.a.income; }, set: function (v) { state.a.income = +v; } }, { - key: 'rrsp', title: 'RRSP / RRIF balance', type: 'number', min: 0, max: 2e7, money: true, + key: 'rrsp', titleK: 'q.rrsp.title', type: 'number', min: 0, max: 2e7, money: true, get: function () { return state.a.rrsp; }, set: function (v) { state.a.rrsp = +v; } }, { - key: 'savings2', title: 'TFSA and non-registered savings', type: 'fields', fields: [ - { label: 'TFSA', key: 'tfsa' }, { label: 'Non-registered (cash, stocks, mutual funds)', key: 'nonreg' } + key: 'savings2', titleK: 'q.savings2.title', type: 'fields', fields: [ + { labelK: 'q.savings2.tfsa', key: 'tfsa' }, { labelK: 'q.savings2.nonreg', key: 'nonreg' } ], get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; } }, { - key: 'contrib', title: 'Monthly savings into RRSP', hint: 'Your contribution + any employer match.', type: 'fields', fields: [ - { label: 'You contribute $/mo', key: 'monthlyContribution' }, { label: 'Employer match $/mo', key: 'employerMatchMonthly' } + key: 'contrib', titleK: 'q.contrib.title', hintK: 'q.contrib.hint', type: 'fields', fields: [ + { labelK: 'q.contrib.you', key: 'monthlyContribution' }, { labelK: 'q.contrib.match', key: 'employerMatchMonthly' } ], get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; } }, { - key: 'retire', title: 'Retirement age', hint: 'When you stop earning employment income.', type: 'slider', - min: 55, max: 72, step: 1, fmt: function (v) { return v + ' years'; }, + key: 'retire', titleK: 'q.retire.title', hintK: 'q.retire.hint', type: 'slider', + min: 55, max: 72, step: 1, fmt: function (v) { return t('q.retire.years', { v: v }); }, get: function () { return state.a.retireAge; }, set: function (v) { state.a.retireAge = +v; } }, { - key: 'spend', title: 'Planned annual spending in retirement', hint: 'Today’s dollars, after tax. Be honest — this is the number that decides everything.', + key: 'spend', titleK: 'q.spend.title', hintK: 'q.spend.hint', type: 'number', min: 10000, max: 5e5, money: true, get: function () { return state.a.spendingAnnual; }, set: function (v) { state.a.spendingAnnual = +v; } }, { - key: 'cppstart', title: 'Start CPP at…', hint: function () { - var m = { 60: '−36% for life', 65: 'Full rate', 70: '+42% for life' }; - return 'Age ' + state.a.cppStartAge + ': ' + (m[state.a.cppStartAge] || 'custom adjustment'); + key: 'cppstart', titleK: 'q.cppstart.title', hint: function () { + var m = { 60: 'q.cppstart.m60', 65: 'q.cppstart.m65', 70: 'q.cppstart.m70' }; + return t('q.cppstart.hint', { age: state.a.cppStartAge, adj: t(m[state.a.cppStartAge] || 'q.cppstart.mCustom') }); }, - type: 'opts', opts: [[60, '60', '−36% for life'], [65, '65', 'full rate'], [70, '70', '+42% for life']], + type: 'opts', opts: [[60, '60', 'q.cppstart.os60'], [65, '65', 'q.cppstart.os65'], [70, '70', 'q.cppstart.os70']], get: function () { return state.a.cppStartAge; }, set: function (v) { state.a.cppStartAge = +v; } }, { - key: 'oasstart', title: 'Start OAS at…', hint: 'Deferring to 70 is +36% for life, plus a bigger GIS window.', - type: 'opts', opts: [[65, '65', 'on eligibility'], [70, '70', '+36% for life']], + key: 'oasstart', titleK: 'q.oasstart.title', hintK: 'q.oasstart.hint', + type: 'opts', opts: [[65, '65', 'q.oasstart.os65'], [70, '70', 'q.oasstart.os70']], get: function () { return state.a.oasStartAge; }, set: function (v) { state.a.oasStartAge = +v; } }, { - key: 'extra', title: 'Other income', hint: 'DB pension, rental, royalties… leave 0 if none.', type: 'fields', fields: [ - { label: 'Pension $/yr', key: 'pensionAnnual' }, { label: 'Rental $/yr', key: 'rentalAnnual' }, { label: 'Other $/yr', key: 'otherAnnual' } + key: 'extra', titleK: 'q.extra.title', hintK: 'q.extra.hint', type: 'fields', fields: [ + { labelK: 'q.extra.pension', key: 'pensionAnnual' }, { labelK: 'q.extra.rental', key: 'rentalAnnual' }, { labelK: 'q.extra.other', key: 'otherAnnual' } ], get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; } }, { - key: 'assumptions', title: 'Assumptions', hint: 'Real (above-inflation) returns. Defaults match a balanced portfolio.', type: 'sliders', sliders: [ - { label: 'Return before retirement', key: 'realReturnPre', min: 0, max: 8, step: 0.25, fmt: pct2 }, - { label: 'Return in retirement', key: 'realReturnPost', min: 0, max: 6, step: 0.25, fmt: pct2 }, - { label: 'Inflation', key: 'inflation', min: 0, max: 5, step: 0.25, fmt: pct2 }, - { label: 'Plan to age', key: 'lifeExp', min: 80, max: 100, step: 1, fmt: function (v) { return v + ' yrs'; } } + key: 'assumptions', titleK: 'q.assumptions.title', hintK: 'q.assumptions.hint', type: 'sliders', sliders: [ + { labelK: 'q.assumptions.pre', key: 'realReturnPre', min: 0, max: 8, step: 0.25, fmt: pct2 }, + { labelK: 'q.assumptions.post', key: 'realReturnPost', min: 0, max: 6, step: 0.25, fmt: pct2 }, + { labelK: 'q.assumptions.inf', key: 'inflation', min: 0, max: 5, step: 0.25, fmt: pct2 }, + { labelK: 'q.assumptions.life', key: 'lifeExp', min: 80, max: 100, step: 1, fmt: function (v) { return t('q.assumptions.yrs', { v: v }); } } ], get: function (f) { return state.a[f]; }, set: function (f, v) { state.a[f] = +v; } }, { - key: 'vision', title: 'What are you retiring toward?', hint: 'Pick a few. We’ll paint your plan with it.', + key: 'vision', titleK: 'q.vision.title', hintK: 'q.vision.hint', type: 'chips', opts: INTERESTS, get: function () { return state.a.interests; }, set: function (v) { state.a.interests = v; } } ]; - function pct2(v) { return v.toFixed(2).replace(/0$/, '') + '%'; } function visibleSteps() { return STEPS.filter(function (s) { return !s.show || s.show(); }); } @@ -210,37 +214,37 @@ if (state.step >= vs.length) state.step = vs.length - 1; var s = vs[state.step]; $('#prog-bar').style.width = Math.round((state.step / vs.length) * 100) + '%'; - $('#step-count').textContent = (state.step + 1) + ' / ' + vs.length; + $('#step-count').textContent = t('quiz.stepCount', { i: state.step + 1, n: vs.length }); $('#btn-back').style.visibility = state.step === 0 ? 'hidden' : 'visible'; - btnNext.textContent = state.step === vs.length - 1 ? 'See my plan →' : 'Next →'; + btnNext.textContent = state.step === vs.length - 1 ? t('quiz.seePlan') : t('quiz.next'); - var hintTxt = typeof s.hint === 'function' ? s.hint() : (s.hint || ''); - var h = '

' + s.title + '

' + (hintTxt ? '

' + hintTxt + '

' : ''); + var hintTxt = typeof s.hint === 'function' ? s.hint() : (s.hintK ? t(s.hintK) : ''); + var h = '

' + t(s.titleK) + '

' + (hintTxt ? '

' + hintTxt + '

' : ''); if (s.type === 'grid') { h += '
' + s.opts.map(function (o) { - return ''; + return ''; }).join('') + '
'; } else if (s.type === 'opts') { h += '
' + s.opts.map(function (o) { - return ''; + return ''; }).join('') + '
'; } else if (s.type === 'chips') { var cur = s.get() || []; h += '
' + s.opts.map(function (o) { - return ''; + return ''; }).join('') + '
'; } else if (s.type === 'number') { - h += '
'; + h += '
'; } else if (s.type === 'fields') { h += s.fields.map(function (f, i) { - return '
'; + return '
'; }).join(''); } else if (s.type === 'slider') { h += '
' + s.fmt(s.get()) + '
'; } else if (s.type === 'sliders') { h = s.sliders.map(function (sl) { - return '
'; + return '
'; }).join(''); } stepBody.innerHTML = h; @@ -285,9 +289,9 @@ if (s.type === 'number') { var v = +s.get(); if (s.get() === null || s.get() === '' || isNaN(v) || v < s.min || v > s.max) { - ok = false; msg = 'Enter a number between ' + s.min + ' and ' + s.max + '.'; + ok = false; msg = t('v.number', { min: fmtInt(s.min), max: fmtInt(s.max) }); } - } else if (s.type === 'grid' && !s.get()) { ok = false; msg = 'Pick a province to continue.'; } + } else if (s.type === 'grid' && !s.get()) { ok = false; msg = t('v.province'); } btnNext.disabled = !ok; var old = stepBody.querySelector('.err'); if (old) old.remove(); if (!ok && msg) { var d = document.createElement('div'); d.className = 'err'; d.textContent = msg; stepBody.appendChild(d); } @@ -304,23 +308,23 @@ var atRet = proj.years.filter(function (r) { return r.age === Math.floor(p.retireAge); })[0] || draw[0]; var g67 = proj.years.filter(function (r) { return r.age === 67; })[0] || { cpp: 0, oasGross: 0 }; var chips = [ - ['Savings at retirement', money(atRet ? atRet.totalBal : 0), ''], - ['CPP + OAS at 67', money(g67.cpp + g67.oasGross) + '/yr', ''], - ['Money lasts to', proj.depletionAge ? 'runs out ~' + proj.depletionAge : (p.lifeExp + '+'), proj.depletionAge ? 'bad' : 'good'], - ['Lifetime tax', k(proj.totalTax + proj.totalClawback), ''] + [t('panel.savingsAt'), money(atRet ? atRet.totalBal : 0), ''], + [t('panel.cppOas67'), money(g67.cpp + g67.oasGross) + t('unit.yr'), ''], + [t('panel.lastsTo'), proj.depletionAge ? t('panel.runsOut', { age: proj.depletionAge }) : (p.lifeExp + '+'), proj.depletionAge ? 'bad' : 'good'], + [t('panel.lifetimeTax'), k(proj.totalTax + proj.totalClawback), ''] ]; $('#panel-chips').innerHTML = chips.map(function (c) { return '
' + c[0] + '
' + c[1] + '
'; }).join(''); - var ins = ''; - if (proj.depletionAge) ins = '⚠️ On this path you run out of money around age ' + proj.depletionAge + '. Keep answering — the plan ends with three concrete ways to close the gap.'; - else if (p.cppStartAge < 65) ins = 'CPP at ' + p.cppStartAge + ' is permanently reduced — deferring to 70 is a +42% inflation-proof raise. Move the slider later and watch the panel.'; - else if (p.oasStartAge >= 70) ins = 'OAS at ' + p.oasStartAge + ': +36% for life, plus a bigger GIS window in between. Good trade for most people.'; - else if (p.province === 'QC') ins = 'ℹ️ Québec: QPP is not modelled — add your QPP estimate as “Pension $/yr”. QPP typically replaces ~35% of income.'; - else if (p.tfsa < 15000 && p.rrsp > 200000) ins = 'You’ll hit the RRIF-minimum tax cliff at 71. TFSA room now softens it later — check the gap cards.'; - else if (p.household === 'couple') ins = 'With pension splitting + household GIS, your joint taxes drop — the panel shows the joint total.'; - else ins = 'Live numbers — change any answer and this panel moves.'; + var ins; + if (proj.depletionAge) ins = t('i.depletion', { age: proj.depletionAge }); + else if (p.cppStartAge < 65) ins = t('i.cppEarly', { age: p.cppStartAge }); + else if (p.oasStartAge >= 70) ins = t('i.oasLate', { age: p.oasStartAge }); + else if (p.province === 'QC') ins = t('i.qc'); + else if (p.tfsa < 15000 && p.rrsp > 200000) ins = t('i.cliff'); + else if (p.household === 'couple') ins = t('i.couple'); + else ins = t('i.live'); $('#panel-insight').innerHTML = ins; syncSheetH(); /* panel content just changed height — keep --sheet-h live (round 2 Fix 1) */ } @@ -349,39 +353,40 @@ for (i = 0; i <= yt; i++) { var v = ymax * i / yt, y = Y(v); s += '' + - '' + (opts && opts.money === false ? v : k(v)) + ''; + '' + (opts && opts.money === false ? fmtInt(v) : k(v)) + ''; } var step = a1 - a0 > 34 ? 10 : 5; for (var a = Math.ceil(a0 / step) * step; a <= a1; a += step) { var x = X(a); - s += '' + a + ''; + s += '' + fmtInt(a) + ''; } - s += 'age'; + s += '' + t('chart.age') + ''; return s; } function svgWrap(inner) { return '' + inner + ''; } function legend(items) { - return '
' + items.map(function (t) { - return '' + t[0] + ''; + return '
' + items.map(function (x) { + return '' + x[0] + ''; }).join('') + '
'; } /* Round 2 Fix 2: one chart() helper renders each card = header (.chart-h: h3 + caption) + SVG (+ legend). The "swipe →" hint is appended inside .chart-h — in the header row it can never overlap SVG content (the old .chartbox::before overlay sat on the top y-tick). - Card titles/captions moved here from index.html so every render keeps the header. */ - var CHART_SWIPE = 'swipe →'; + Card titles/captions live here (i18n keys) so every render keeps the header in the + active language. */ + function chartSwipe() { return '' + t('chart.swipe') + ''; } var CHART_HDR = { - '#g1': '

Will it last? Projected savings

Total portfolio (RRSP + TFSA + non-registered), in today\'s dollars. Shaded: money running out.

', - '#g2': '

Where retirement income comes from

CPP, OAS, GIS and what you must withdraw from savings — stacked, after-tax view.

', - '#g3': '

Spending vs. what you\'ll actually have

Annual cash in (income after tax & clawback, plus withdrawals) vs. planned spending.

', - '#g4': function () { return '

Four futures, one number

Sustainable annual spending that lasts to ' + state.a.lifeExp + ' under each scenario.

'; } + '#g1': function () { return '

' + t('g1.h') + '

' + t('g1.cap') + '

'; }, + '#g2': function () { return '

' + t('g2.h') + '

' + t('g2.cap') + '

'; }, + '#g3': function () { return '

' + t('g3.h') + '

' + t('g3.cap') + '

'; }, + '#g4': function () { return '

' + t('g4.h') + '

' + t('g4.cap', { age: state.a.lifeExp }) + '

'; } }; function chartH(sel) { var hd = typeof CHART_HDR[sel] === 'function' ? CHART_HDR[sel]() : (CHART_HDR[sel] || ''); - return '
' + hd + '
' + CHART_SWIPE + '
'; + return '
' + hd + '
' + chartSwipe() + '
'; } function chart(sel, inner, legendItems) { $(sel).innerHTML = chartH(sel) + (inner || '') + (legendItems ? legend(legendItems) : ''); @@ -400,24 +405,24 @@ if (proj.depletionAge) { var x = X(proj.depletionAge); inner += '' + - 'runs out ≈ ' + proj.depletionAge + ''; + '' + t('chart.runsOut', { age: fmtInt(proj.depletionAge) }) + ''; } var ret = Math.max(a0, inputs().retireAge); inner += '' + - 'retire'; - chart('#g1', svgWrap(inner), [['Total savings (today’s $)', COL.bal]]); + '' + t('chart.retire') + ''; + chart('#g1', svgWrap(inner), [[t('g1.legend'), COL.bal]]); } function g2_income(proj, p) { var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; }); - if (!yrs.length) { chart('#g2', '

Retirement after your plan horizon.

'); return; } + if (!yrs.length) { chart('#g2', '

' + t('g2.empty') + '

'); return; } var ages = yrs.map(function (r) { return r.age; }); var X = sc(ages, ages[0], ages[ages.length - 1]); var series = [ - ['CPP', COL.cpp, yrs.map(function (r) { return r.cpp; })], - ['OAS (net)', COL.oas, yrs.map(function (r) { return Math.max(0, r.oasGross - r.oasClawback); })], - ['GIS', COL.gis, yrs.map(function (r) { return r.gis; })], - ['From savings', COL.wdr, yrs.map(function (r) { return r.rrifWithdrawal + r.nonregW + r.tfsaW; })] + [t('g2.cpp'), COL.cpp, yrs.map(function (r) { return r.cpp; })], + [t('g2.oas'), COL.oas, yrs.map(function (r) { return Math.max(0, r.oasGross - r.oasClawback); })], + [t('g2.gis'), COL.gis, yrs.map(function (r) { return r.gis; })], + [t('g2.savings'), COL.wdr, yrs.map(function (r) { return r.rrifWithdrawal + r.nonregW + r.tfsaW; })] ]; var n = ages.length, tot = [], i, acc; for (i = 0; i < n; i++) tot[i] = series.reduce(function (s, se) { return s + se[2][i]; }, 0); @@ -446,26 +451,26 @@ var first = yrs.map(function (r, i) { return r.unfunded > 1 ? ages[i] : null; }).filter(function (x) { return x; })[0]; if (first) { inner += '' + - 'shortfall from ~' + first + ''; + '' + t('g3.shortfall', { age: fmtInt(first) }) + ''; } - chart('#g3', svgWrap(inner), [['Cash you’ll have (after tax)', COL.cash], ['Planned spending', COL.need]]); + chart('#g3', svgWrap(inner), [[t('g3.cash'), COL.cash], [t('g3.need'), COL.need]]); } var g4cache = null; function g4_bars(p, fast) { - if (g4cache && fast) { drawG4(g4cache); $('#g4-note').textContent = 'Computed from the current answers.'; return; } - chart('#g4', '

Calculating scenarios…

'); + if (g4cache && fast) { drawG4(g4cache); $('#g4-note').textContent = t('g4.computed'); return; } + chart('#g4', '

' + t('g4.calc') + '

'); setTimeout(function () { var scen = [ - ['Current plan', p], - ['+$200/mo to RRSP', Object.assign({}, p, { monthlyContribution: p.monthlyContribution + 200, employerMatchMonthly: p.employerMatchMonthly })], - ['Retire 2 years later', Object.assign({}, p, { retireAge: Math.min(p.retireAge + 2, 75) })], - ['CPP at 70', Object.assign({}, p, { cppStartAge: 70 })] + [t('g4.s0'), p], + [t('g4.s1'), Object.assign({}, p, { monthlyContribution: p.monthlyContribution + 200, employerMatchMonthly: p.employerMatchMonthly })], + [t('g4.s2'), Object.assign({}, p, { retireAge: Math.min(p.retireAge + 2, 75) })], + [t('g4.s3'), Object.assign({}, p, { cppStartAge: 70 })] ]; var res = scen.map(function (s) { return [s[0], E.sustainableSpending(s[1]).annual]; }); g4cache = res; drawG4(res); var best = res.slice().sort(function (a, b) { return b[1] - a[1]; })[0]; - $('#g4-note').textContent = 'Biggest lever here: “' + best[0] + '” → ' + money(best[1]) + '/yr, forever.'; + $('#g4-note').textContent = t('g4.best', { name: best[0], money: money(best[1]) }); }, 30); } function drawG4(res) { @@ -501,6 +506,10 @@ .catch(function () { visionManifest = visionManifest || null; }) .then(function () { if (state.screen === 'results') renderVision(state.a); }); + function capText(slug) { + var cap = CAPTIONS[slug] || CAPTIONS.hero; + return cap[getLang()] || cap.en; + } function renderVision(a) { var el = $('#vision-card'); var provSlugs = PROV_SLUGS[a.province] || []; @@ -517,13 +526,14 @@ var best = keys.map(function (s) { return [s, score(s)]; }).sort(function (x, y) { return y[1] - x[1]; })[0]; if (!best) best = ['hero', 0]; var slug = best[1] > 0 ? best[0] : 'hero'; - var cap = CAPTIONS[slug] || CAPTIONS.hero; + var cap = capText(slug); + var label = t('vision.label', { slug: slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) }); var img = visionManifest && visionManifest[slug] ? 'vision/' + visionManifest[slug] : null; if (img) { el.className = 'vision'; - el.innerHTML = 'Your retirement vision' + - '
Retirement Vision — ' + slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) + '
' + cap + - ' AI-generated preview (z-image-turbo). Your numbers: below.
'; + el.innerHTML = '' + t('vision.alt') + '' + + '
' + label + '
' + cap + + ' ' + t('vision.ai') + '
'; var im = el.querySelector('img'); im.onerror = function () { grad(el, slug, cap); }; } else grad(el, slug, cap); @@ -532,7 +542,7 @@ el.className = 'vision grad'; var h = 0; for (var i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) >>> 0; el.style.background = GRADS[h % GRADS.length]; - el.innerHTML = '
Retirement Vision — ' + slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) + '
' + cap + '
'; + el.innerHTML = '
' + t('vision.label', { slug: slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) }) + '
' + cap + '
'; } /* ---------- results ---------- */ @@ -542,14 +552,13 @@ var ss = E.sustainableSpending(p).annual; var v = $('#verdict'), vh = $('#verdict-h'); + vh.textContent = t('r.verdictH'); if (proj.depletionAge) { v.className = 'verdict bad'; - v.innerHTML = 'On this exact path you run out of money at age ' + proj.depletionAge + '. ' + - 'You can safely spend ' + money(ss) + '/yr to 95 (you’re planning ' + money(p.spendingAnnual) + '). The three cards below close the gap — each one, by itself.'; + v.innerHTML = t('r.depletion', { age: fmtInt(proj.depletionAge), ss: money(ss), le: p.lifeExp, sp: money(p.spendingAnnual) }); } else { v.className = 'verdict good'; - v.innerHTML = 'This plan survives to 95 with ' + k(proj.finalBal) + ' left over — you can sustain about ' + money(ss) + '/yr. ' + - 'That’s ' + money(ss - p.spendingAnnual) + '/yr of headroom above your plan.'; + v.innerHTML = t('r.good', { le: p.lifeExp, bal: k(proj.finalBal), ss: money(ss), hr: money(ss - p.spendingAnnual) }); } /* lifeexp-lbl now lives inside the g4 card header (rendered below via chart('#g4')); the header bakes in state.a.lifeExp, so just sync if the node exists (re-renders). */ @@ -557,22 +566,22 @@ var cards = ''; if (rec.gapAnnual > 0) { - cards += '

The gap

' + money(rec.gapAnnual) + '/yr

between your planned spending and what the plan can safely fund.

'; - cards += '

💪 Save more

+' + money(rec.extraMonthly) + '/mo
' + - (rec.extraMonthly > 0 ? '

extra RRSP room from today closes the gap entirely. ~' + pct(rec.extraMonthly / Math.max(1, p.income / 12)) + ' of income.

' : '

Not closable by saving alone — see the other two levers.

') + '
'; - cards += '

🕰 Work longer

' + (rec.retireDelayYears ? '+' + rec.retireDelayYears + (rec.retireDelayYears === 1 ? ' year' : ' years') : '—') + '
' + - (rec.retireDelayYears ? '

retiring at ' + Math.min(p.retireAge + rec.retireDelayYears, 75) + ' instead of ' + p.retireAge + ' closes the gap — and CPP/OAS grow while you do.

' : '

Within 10 extra years, delaying retirement alone can’t close this gap — combine levers.

') + '
'; - cards += '

🌿 Trim spending

−' + money(rec.spendingReduction) + '/yr

to ' + money(Math.max(0, p.spendingAnnual - rec.spendingReduction)) + '. The cheapest lever, and the one nobody talks about at 45.

'; + cards += '

' + t('g.gapH') + '

' + money(rec.gapAnnual) + t('unit.yr') + '

' + t('g.gapP') + '

'; + cards += '

' + t('g.saveH') + '

+' + money(rec.extraMonthly) + t('unit.mo') + '
' + + (rec.extraMonthly > 0 ? '

' + t('g.saveP', { pct: pct(rec.extraMonthly / Math.max(1, p.income / 12)) }) + '

' : '

' + t('g.saveP2') + '

') + '
'; + cards += '

' + t('g.workH') + '

' + (rec.retireDelayYears ? '+' + rec.retireDelayYears + ' ' + t(rec.retireDelayYears === 1 ? 'g.workY1' : 'g.workYN', { n: rec.retireDelayYears }) : '—') + '
' + + (rec.retireDelayYears ? '

' + t('g.workP', { at: Math.min(p.retireAge + rec.retireDelayYears, 75), now: p.retireAge }) + '

' : '

' + t('g.workP2') + '

') + '
'; + cards += '

' + t('g.trimH') + '

−' + money(rec.spendingReduction) + t('unit.yr') + '

' + t('g.trimP', { to: money(Math.max(0, p.spendingAnnual - rec.spendingReduction)) }) + '

'; } else { - cards = '

✅ Status

On track

No gap at ' + money(p.spendingAnnual) + '/yr. Consider testing a bigger number with the what-if sliders.

' + - '

Headroom

' + money(ss - p.spendingAnnual) + '/yr

you could add to spending — or save for the cottage.

' + - '

🛡 Stress-tested

' + money(ss) + '/yr

sustainable real spending to ' + p.lifeExp + ' with RRIF minimums and taxes enforced.

'; + cards = '

' + t('g.statusH') + '

' + t('g.onTrack') + '

' + t('g.statusP', { sp: money(p.spendingAnnual) }) + '

' + + '

' + t('g.headH') + '

' + money(ss - p.spendingAnnual) + t('unit.yr') + '

' + t('g.headP') + '

' + + '

' + t('g.stressH') + '

' + money(ss) + t('unit.yr') + '

' + t('g.stressP', { age: p.lifeExp }) + '

'; } $('#gap-cards').innerHTML = cards; g1_balance(proj); g2_income(proj, p); g3_cash(proj); g4_bars(p, fast); - var tbl = 'AgeRRSPTFSANon-regTotalWithdrawCPPOASGISTax+clawUnfunded'; + var tbl = '' + t('t.age') + '' + t('t.rrsp') + '' + t('t.tfsa') + '' + t('t.nonreg') + '' + t('t.total') + '' + t('t.wdr') + '' + t('t.cpp') + '' + t('t.oas') + '' + t('t.gis') + '' + t('t.tax') + '' + t('t.unfunded') + ''; var n = 0; proj.years.forEach(function (r) { if (r.phase !== 'draw' && r.age % 5 !== 0) return; n++; @@ -581,10 +590,13 @@ '' + k(r.cpp) + '' + k(r.oasGross - r.oasClawback) + '' + k(r.gis) + '' + k(r.tax + r.oasClawback) + '' + (r.unfunded > 1 ? k(r.unfunded) : '—') + ''; }); - $('#yr-table').innerHTML = tbl; $('#yr-n').textContent = n; + $('#yr-table').innerHTML = tbl; $('#yr-summary').innerHTML = t('r.yearlySummary', { n: n }); - var m2 = function (v) { return '$' + Number(v).toLocaleString('en-CA', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }; - $('#stamp').textContent = 'tax rates 2026 · CPP max ' + m2(C.cppMax65Monthly.value) + '/mo · OAS max ' + m2(C.oasMax6574Monthly.value) + '/mo · GIS max ' + m2(C.gisMaxSingleMonthly.value) + '/mo · constants as of ' + C.asOf + ' · RRIF table CRA Reg. 8301(1)'; + var m2 = fmtMoney2; + $('#stamp').textContent = t('r.stamp', { + cpp: m2(C.cppMax65Monthly.value), oas: m2(C.oasMax6574Monthly.value), + gis: m2(C.gisMaxSingleMonthly.value), asOf: C.asOf + }); var a = state.a; $('#wi-spend').value = (state.wi.spend != null ? state.wi.spend : a.spendingAnnual); @@ -598,9 +610,9 @@ } function wiLabels() { $('#wi-spend-o').textContent = money(+$('#wi-spend').value); - $('#wi-ctrb-o').textContent = money(+$('#wi-ctrb').value) + '/mo'; - $('#wi-ret-o').textContent = $('#wi-ret').value + ' yrs'; - $('#wi-cpp-o').textContent = 'age ' + $('#wi-cpp').value; + $('#wi-ctrb-o').textContent = money(+$('#wi-ctrb').value) + t('unit.mo'); + $('#wi-ret-o').textContent = t('r.wiYears', { v: $('#wi-ret').value }); + $('#wi-cpp-o').textContent = t('r.wiAge', { v: $('#wi-cpp').value }); } var wiTimer = null; function wiChange(key, el) { @@ -622,7 +634,7 @@ }; $('#btn-edit').onclick = function () { showScreen('quiz'); renderStep(); liveUpdate(); }; $('#btn-restart').onclick = function () { - if (!confirm('Start over? Clears saved answers in this browser.')) return; + if (!confirm(t('restart.confirm'))) return; try { localStorage.removeItem(LS); } catch (e) { } history.replaceState(null, '', location.pathname); state.a = defaults(); state.wi = {}; state.step = 0; g4cache = null; @@ -631,12 +643,12 @@ $('#btn-print').onclick = function () { window.print(); }; $('#btn-share').onclick = function () { var url = location.origin + location.pathname + '#p=' + encState(); - var restore = function () { $('#btn-share').textContent = '🔗 Copy link with my answers'; }; - var done = function () { $('#btn-share').textContent = '✅ Link copied'; setTimeout(restore, 1800); }; + var restore = function () { $('#btn-share').textContent = t('res.share'); }; + var done = function () { $('#btn-share').textContent = t('share.copied'); setTimeout(restore, 1800); }; /* Fix 7: never claim "copied" when the clipboard API is missing or rejected. */ var fail = function () { - window.prompt('Copy your link:', url); - $('#btn-share').textContent = '🔗 Link shown — copy it'; + window.prompt(t('share.prompt'), url); + $('#btn-share').textContent = t('share.shown'); setTimeout(restore, 1800); }; if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(url).then(done, fail); @@ -673,6 +685,16 @@ syncScrim(); syncSheetH(); }); + + /* Language toggle (i18n.js setLang → applyI18n → this): rebuild the active screen in the + new language. Static DOM is already handled by applyI18n; charts/cards/vision need a re-render. */ + window.__onLangChange = function () { + g4cache = null; + if (state.screen === 'quiz') { renderStep(); updatePanel(); } + else if (state.screen === 'results') { renderResults(false); } + save(); + }; + var savedScreen = state.screen, savedStep = state.step; showScreen(savedScreen); if (savedScreen === 'quiz') { renderStep(); liveUpdate(); } diff --git a/site/i18n.js b/site/i18n.js new file mode 100644 index 0000000..10bf7ad --- /dev/null +++ b/site/i18n.js @@ -0,0 +1,558 @@ +/* Retire Planner i18n — dependency-free, loads before app.js. + EN/FR dictionary (dot-path keys), t() with {placeholder} interpolation, lang state + (payload > saved state > navigator), Intl formatters, applyI18n(), header EN|FR toggle. + Exposes globals: I18N, t, getLang, setLang, detectLang, applyI18n, + fmtMoney, fmtMoneyK, fmtMoney2, fmtInt, fmtPct. */ +(function () { + 'use strict'; + var LS_KEY = 'retire-planner-v1'; + var MINUS = '\u2212'; /* − */ + var NB = '\u00a0'; /* no-break space (French punctuation) */ + var NNB = '\u202f'; /* narrow no-break space (French units, matches Intl fr-CA) */ + + /* ---------- dictionary ---------- */ + var EN = { + doc: { + title: 'Retire Planner — Canadian retirement, modelled properly', + desc: 'A Canadian retirement calculator with real CPP/OAS/GIS/RRIF mechanics and live 2026 tax rates. 100% client-side — answers never leave your browser.' + }, + brand: 'Retire Planner', + hdr: { + pill: '🔒 100% client-side — answers never leave your browser', + pillTitle: 'No server, no tracking, no accounts', + startOver: 'Start over' + }, + intro: { + h1: 'Will you actually run out of money?', + lede: 'Most Canadian retirement calculators stop at “your RRSP grows at 6%”. This one models real CPP/OAS/GIS/RRIF mechanics, provincial taxes fetched live, and the tax cliff most planners won\'t show you.', + tick1: 'CPP early/late (−0.6%/mo before 65, +0.7% after), OAS deferral, clawback & GIS', + tick2: 'Live 2026 federal + all 13 province/territory tax tables', + tick3: 'RRIF minimums at 71, pension splitting, tax-smart drawdown', + tick4: 'Couples: joint taxes, split pensions, household GIS', + privacy: '🔒 100% client-side — answers never leave your browser. No account, no tracking, nothing is sent anywhere.', + cta: 'Build my retirement plan →', + fine: '~2 minutes. Works on one screen at a time; you can change anything later.' + }, + quiz: { + back: '← Back', + next: 'Next →', + seePlan: 'See my plan →', + stepCount: '{i} / {n}' + }, + panel: { + title: 'Your plan so far', + savingsAt: 'Savings at retirement', + cppOas67: 'CPP + OAS at 67', + lastsTo: 'Money lasts to', + runsOut: 'runs out ~{age}', + lifetimeTax: 'Lifetime tax' + }, + unit: { yr: '/yr', mo: '/mo' }, + fields: { unit: '($)' }, + i: { + depletion: '⚠️ On this path you run out of money around age {age}. Keep answering — the plan ends with three concrete ways to close the gap.', + cppEarly: 'CPP at {age} is permanently reduced — deferring to 70 is a +42% inflation-proof raise. Move the slider later and watch the panel.', + oasLate: 'OAS at {age}: +36% for life, plus a bigger GIS window in between. Good trade for most people.', + qc: 'ℹ️ Québec: QPP is not modelled — add your QPP estimate as “Pension $/yr”. QPP typically replaces ~35% of income.', + cliff: 'You’ll hit the RRIF-minimum tax cliff at 71. TFSA room now softens it later — check the gap cards.', + couple: 'With pension splitting + household GIS, your joint taxes drop — the panel shows the joint total.', + live: 'Live numbers — change any answer and this panel moves.' + }, + prov: { + ON: 'Ontario', BC: 'British Columbia', AB: 'Alberta', SK: 'Saskatchewan', + MB: 'Manitoba', QC: 'Quebec', NS: 'Nova Scotia', NB: 'New Brunswick', + NL: 'Newfoundland & Labrador', PE: 'PEI', YT: 'Yukon', NT: 'Northwest Territories', NU: 'Nunavut' + }, + int: { + rockies: '🏔 Mountains', maritimes: '🌊 Ocean', toronto: '🏙 Big city', + dock: '🛶 Lake / dock', muskoka: '🌲 Cottage country', prairies: '🌾 Open sky', + winter: '⛷ Snow / ski', workshop: '🪵 Workshop', garden: '🌺 Garden', + travel: '✈️ Travel', quebec: '🇫🇷 Québec' + }, + q: { + province: { title: 'Where do you live?', hint: 'Taxes, and your retirement view.' }, + age: { title: 'Your age', hint: 'Years.' }, + household: { + title: 'Household', hint: 'Couple means joint taxes, pension splitting and household GIS.', + single: 'Single', couple: 'Couple' + }, + p_age: { title: 'Partner’s age' }, + p_income: { title: 'Partner’s annual employment income', hint: '$/yr before tax. 0 if not working.' }, + p_savings: { + title: 'Partner’s savings', hint: 'Current balances, today’s dollars.', + rrsp: 'RRSP / RRIF', tfsa: 'TFSA', nonreg: 'Non-registered' + }, + income: { title: 'Your annual employment income', hint: '$/yr. Used to estimate your CPP history.' }, + rrsp: { title: 'RRSP / RRIF balance' }, + savings2: { + title: 'TFSA and non-registered savings', + tfsa: 'TFSA', nonreg: 'Non-registered (cash, stocks, mutual funds)' + }, + contrib: { + title: 'Monthly savings into RRSP', hint: 'Your contribution + any employer match.', + you: 'You contribute $/mo', match: 'Employer match $/mo' + }, + retire: { title: 'Retirement age', hint: 'When you stop earning employment income.', years: '{v} years' }, + spend: { title: 'Planned annual spending in retirement', hint: 'Today’s dollars, after tax. Be honest — this is the number that decides everything.' }, + cppstart: { + title: 'Start CPP at…', hint: 'Age {age}: {adj}', + m60: '−36% for life', m65: 'Full rate', m70: '+42% for life', mCustom: 'custom adjustment', + os60: '−36% for life', os65: 'full rate', os70: '+42% for life' + }, + oasstart: { + title: 'Start OAS at…', hint: 'Deferring to 70 is +36% for life, plus a bigger GIS window.', + os65: 'on eligibility', os70: '+36% for life' + }, + extra: { + title: 'Other income', hint: 'DB pension, rental, royalties… leave 0 if none.', + pension: 'Pension $/yr', rental: 'Rental $/yr', other: 'Other $/yr' + }, + assumptions: { + title: 'Assumptions', hint: 'Real (above-inflation) returns. Defaults match a balanced portfolio.', + pre: 'Return before retirement', post: 'Return in retirement', inf: 'Inflation', + life: 'Plan to age', yrs: '{v} yrs' + }, + vision: { title: 'What are you retiring toward?', hint: 'Pick a few. We’ll paint your plan with it.' } + }, + v: { + number: 'Enter a number between {min} and {max}.', + province: 'Pick a province to continue.' + }, + chart: { + age: 'age', swipe: 'swipe →', + runsOut: 'runs out ≈ {age}', retire: 'retire' + }, + g1: { + h: 'Will it last? Projected savings', + cap: 'Total portfolio (RRSP + TFSA + non-registered), in today\'s dollars. Shaded: money running out.', + legend: 'Total savings (today’s $)' + }, + g2: { + h: 'Where retirement income comes from', + cap: 'CPP, OAS, GIS and what you must withdraw from savings — stacked, after-tax view.', + empty: 'Retirement after your plan horizon.', + cpp: 'CPP', oas: 'OAS (net)', gis: 'GIS', savings: 'From savings' + }, + g3: { + h: 'Spending vs. what you\'ll actually have', + cap: 'Annual cash in (income after tax & clawback, plus withdrawals) vs. planned spending.', + shortfall: 'shortfall from ~{age}', + cash: 'Cash you’ll have (after tax)', need: 'Planned spending' + }, + g4: { + h: 'Four futures, one number', + cap: 'Sustainable annual spending that lasts to {age} under each scenario.', + calc: 'Calculating scenarios…', + computed: 'Computed from the current answers.', + best: 'Biggest lever here: “{name}” → {money}/yr, forever.', + s0: 'Current plan', s1: '+$200/mo to RRSP', s2: 'Retire 2 years later', s3: 'CPP at 70' + }, + r: { + verdictH: 'Your plan, in one line', + depletion: 'On this exact path you run out of money at age {age}. ' + + 'You can safely spend {ss}/yr to {le} (you’re planning {sp}). The three cards below close the gap — each one, by itself.', + good: 'This plan survives to {le} with {bal} left over — you can sustain about {ss}/yr. ' + + 'That’s {hr}/yr of headroom above your plan.', + yearlySummary: 'Year-by-year detail ({n} years)', + whatif: 'Try a what-if — move a slider, watch the plan update', + stamp: 'tax rates 2026 · CPP max {cpp}/mo · OAS max {oas}/mo · GIS max {gis}/mo · constants as of {asOf} · RRIF table CRA Reg. 8301(1)', + wiSpend: 'Annual spending', wiCtrb: 'Monthly savings', wiRet: 'Retirement age', wiCpp: 'CPP starts at', + wiYears: '{v} yrs', wiAge: 'age {v}' + }, + g: { + gapH: 'The gap', gapP: 'between your planned spending and what the plan can safely fund.', + saveH: '💪 Save more', + saveP: 'extra RRSP room from today closes the gap entirely. ~{pct} of income.', + saveP2: 'Not closable by saving alone — see the other two levers.', + workH: '🕰 Work longer', workY1: '{n} year', workYN: '{n} years', + workP: 'retiring at {at} instead of {now} closes the gap — and CPP/OAS grow while you do.', + workP2: 'Within 10 extra years, delaying retirement alone can’t close this gap — combine levers.', + trimH: '🌿 Trim spending', trimP: 'to {to}. The cheapest lever, and the one nobody talks about at 45.', + statusH: '✅ Status', onTrack: 'On track', + statusP: 'No gap at {sp}/yr. Consider testing a bigger number with the what-if sliders.', + headH: 'Headroom', headP: 'you could add to spending — or save for the cottage.', + stressH: '🛡 Stress-tested', stressP: 'sustainable real spending to {age} with RRIF minimums and taxes enforced.' + }, + t: { + age: 'Age', rrsp: 'RRSP', tfsa: 'TFSA', nonreg: 'Non-reg', total: 'Total', wdr: 'Withdraw', + cpp: 'CPP', oas: 'OAS', gis: 'GIS', tax: 'Tax+claw', unfunded: 'Unfunded' + }, + d: { + main: 'Estimates only — not financial advice. Simplified model: GIS uses a simplified 50% phase-out; Quebec shown with the 16.5% federal abatement (QPP and Quebec payroll taxes not modelled); DB pensions not modelled; returns are constant real rates; Ontario surtax modelled on base tax.', + privacy: '🔒 100% client-side — answers never leave your browser. Your answers are saved only in this browser (localStorage).' + }, + res: { + print: '🖨 Print / save PDF', + share: '🔗 Copy link with my answers', + edit: '✎ Edit answers' + }, + share: { + copied: '✅ Link copied', + shown: '🔗 Link shown — copy it', + prompt: 'Copy your link:' + }, + restart: { confirm: 'Start over? Clears saved answers in this browser.' }, + vision: { + alt: 'Your retirement vision', + label: 'Retirement Vision — {slug}', + ai: 'AI-generated preview (z-image-turbo). Your numbers: below.' + } + }; + + var FR = { + doc: { + title: 'Planificateur de retraite — la retraite canadienne, modélisée comme il faut', + desc: 'Une calculatrice de retraite canadienne avec les vraies mécaniques RPC/SV/SRG/FERR et les taux d\'impôt 2026 en direct. 100 % côté client — vos réponses ne quittent jamais votre navigateur.' + }, + brand: 'Planificateur de retraite', + hdr: { + pill: '🔒 100 % côté client — vos réponses ne quittent jamais votre navigateur', + pillTitle: 'Aucun serveur, aucun suivi, aucun compte', + startOver: 'Recommencer' + }, + intro: { + h1: 'Allez-vous vraiment manquer d\'argent ?', + lede: 'La plupart des calculateurs de retraite canadiens s\'arrêtent à « votre REER croît de 6 % ». Celui-ci modélise les vraies mécaniques RPC/SV/SRG/FERR, les impôts provinciaux obtenus en direct et le palier fiscal que la plupart des planificateurs ne vous montrent pas.', + tick1: 'RPC hâtive ou tardive (−0,6 %/mois avant 65 ans, +0,7 % après), report de la SV, récupération et SRG', + tick2: 'Barèmes fédéraux 2026 en direct + les 13 tables fiscales provinciales et territoriales', + tick3: 'Minimums de FERR à 71 ans, fractionnement du revenu de pension, retraits fiscalement intelligents', + tick4: 'Couples : impôts conjoints, pensions fractionnées, SRG du ménage', + privacy: '🔒 100 % côté client — vos réponses ne quittent jamais votre navigateur. Aucun compte, aucun suivi, rien n\'est envoyé nulle part.', + cta: 'Élaborer mon plan de retraite →', + fine: 'Environ 2 minutes. Un écran à la fois; vous pourrez tout modifier plus tard.' + }, + quiz: { + back: '← Précédent', + next: 'Suivant →', + seePlan: 'Voir mon plan →', + stepCount: '{i} / {n}' + }, + panel: { + title: 'Votre plan jusqu\'ici', + savingsAt: 'Épargne à la retraite', + cppOas67: 'RPC + SV à 67', + lastsTo: 'L\'argent dure jusqu\'à', + runsOut: 'épuisé ~{age}', + lifetimeTax: 'Impôt à vie' + }, + unit: { yr: '/an', mo: '/mois' }, + fields: { unit: '($)' }, + i: { + depletion: '⚠️ Sur ce parcours, vous manquez d\'argent vers {age} ans. Continuez — le plan se termine par trois façons concrètes de combler l\'écart.', + cppEarly: 'La RPC à {age} ans est réduite de façon permanente — reporter à 70 ans équivaut à +42 % à l\'abri de l\'inflation. Bougez le curseur et regardez le panneau.', + oasLate: 'SV à {age} ans : +36 % pour la vie, et une plus grande fenêtre de SRG entre-temps. Un bon compromis pour la plupart des gens.', + qc: 'ℹ️ Québec : le RRQ n\'est pas modélisé — ajoutez votre estimation du RRQ sous « Pension ($/an) ». Le RRQ remplace habituellement ~35 % du revenu.', + cliff: 'Vous heurterez le palier fiscal du minimum de FERR à 71 ans. Du CELI aujourd\'hui en atténue l\'effet plus tard — voyez les cartes d\'écart.', + couple: 'Avec le fractionnement du revenu de pension et la SRG du ménage, vos impôts conjoints baissent — le panneau affiche le total conjoint.', + live: 'Chiffres en direct — changez une réponse et ce panneau bouge.' + }, + prov: { + ON: 'Ontario', BC: 'Colombie-Britannique', AB: 'Alberta', SK: 'Saskatchewan', + MB: 'Manitoba', QC: 'Québec', NS: 'Nouvelle-Écosse', NB: 'Nouveau-Brunswick', + NL: 'Terre-Neuve-et-Labrador', PE: 'Île-du-Prince-Édouard', YT: 'Yukon', NT: 'Territoires du Nord-Ouest', NU: 'Nunavut' + }, + int: { + rockies: '🏔 Montagnes', maritimes: '🌊 Océan', toronto: '🏙 Grande ville', + dock: '🛶 Lac / quai', muskoka: '🌲 Pays des chalets', prairies: '🌾 Grands espaces', + winter: '⛷ Neige / ski', workshop: '🪵 Atelier', garden: '🌺 Jardin', + travel: '✈️ Voyages', quebec: '🇫🇷 Québec' + }, + q: { + province: { title: 'Où habitez-vous ?', hint: 'Vos impôts — et votre décor de retraite.' }, + age: { title: 'Votre âge', hint: 'Années.' }, + household: { + title: 'Ménage', hint: 'Un couple : impôts conjoints, fractionnement du revenu de pension et SRG du ménage.', + single: 'Célibataire', couple: 'Couple' + }, + p_age: { title: 'Âge du conjoint' }, + p_income: { title: 'Revenu d\'emploi annuel du conjoint', hint: '$/an avant impôt. 0 si sans emploi.' }, + p_savings: { + title: 'Épargne du conjoint', hint: 'Soldes actuels, en dollars d\'aujourd\'hui.', + rrsp: 'REER / FERR', tfsa: 'CELI', nonreg: 'Non enregistré' + }, + income: { title: 'Votre revenu d\'emploi annuel', hint: '$/an. Sert à estimer votre historique RPC.' }, + rrsp: { title: 'Solde du REER / FERR' }, + savings2: { + title: 'Épargne CELI et non enregistrée', + tfsa: 'CELI', nonreg: 'Non enregistré (comptant, actions, fonds communs)' + }, + contrib: { + title: 'Épargne mensuelle au REER', hint: 'Votre cotisation + la contrepartie de l\'employeur.', + you: 'Vous cotisez', match: 'Contrepartie de l\'employeur' + }, + retire: { title: 'Âge de la retraite', hint: 'Quand vous cessez de gagner un revenu d\'emploi.', years: '{v} ans' }, + spend: { title: 'Dépenses annuelles prévues à la retraite', hint: 'En dollars d\'aujourd\'hui, après impôt. Soyez honnête — c\'est le chiffre qui décide de tout.' }, + cppstart: { + title: 'Débuter la RPC à…', hint: 'À {age} ans : {adj}', + m60: '−36 % pour la vie', m65: 'Taux plein', m70: '+42 % pour la vie', mCustom: 'ajustement personnalisé', + os60: '−36 % pour la vie', os65: 'taux plein', os70: '+42 % pour la vie' + }, + oasstart: { + title: 'Débuter la SV à…', hint: 'Reporter à 70 ans : +36 % pour la vie, et une plus grande fenêtre de SRG.', + os65: 'dès l\'admissibilité', os70: '+36 % pour la vie' + }, + extra: { + title: 'Autres revenus', hint: 'Pension à prestations, location, redevances… laissez 0 sinon.', + pension: 'Pension par année', rental: 'Location par année', other: 'Autre par année' + }, + assumptions: { + title: 'Hypothèses', hint: 'Rendements réels (supérieurs à l\'inflation). Les valeurs par défaut correspondent à un portefeuille équilibré.', + pre: 'Rendement avant la retraite', post: 'Rendement à la retraite', inf: 'Inflation', + life: 'Planifier jusqu\'à', yrs: '{v} ans' + }, + vision: { title: 'Vers quoi prenez-vous votre retraite ?', hint: 'Choisissez-en quelques-uns. Nous en imprégnerons votre plan.' } + }, + v: { + number: 'Entrez un nombre entre {min} et {max}.', + province: 'Choisissez une province pour continuer.' + }, + chart: { + age: 'âge', swipe: 'glissez →', + runsOut: 'épuisé ≈ {age}', retire: 'retraite' + }, + g1: { + h: 'L\'épargne tiendra-t-elle ?', + cap: 'Portefeuille complet (REER + CELI + non enregistré), en dollars d\'aujourd\'hui. Zone ombrée : épargne épuisée.', + legend: 'Épargne totale ($ d\'aujourd\'hui)' + }, + g2: { + h: 'D\'où vient le revenu de retraite', + cap: 'RPC, SV, SRG et ce que vous retirez de l\'épargne — empilé, après impôt.', + empty: 'Retraite après l\'horizon du plan.', + cpp: 'RPC', oas: 'SV (net)', gis: 'SRG', savings: 'Épargne' + }, + g3: { + h: 'Dépenses vs ce que vous aurez', + cap: 'Encaisse annuelle (revenu après impôt et récupération, plus retraits) vs dépenses prévues.', + shortfall: 'déficit dès ~{age}', + cash: 'Encaisse disponible (après impôt)', need: 'Dépenses prévues' + }, + g4: { + h: 'Quatre scénarios, un chiffre', + cap: 'Dépenses annuelles durables jusqu\'à {age} ans, par scénario.', + calc: 'Calcul des scénarios…', + computed: 'Calculé à partir des réponses actuelles.', + best: 'Le levier le plus puissant ici : « {name} » → {money}/an, pour toujours.', + s0: 'Plan actuel', s1: '+200 $/mois au REER', s2: 'Retard de 2 ans', s3: 'RPC à 70' + }, + r: { + verdictH: 'Votre plan, en une phrase', + depletion: 'Sur ce parcours exact, vous manquez d\'argent à {age} ans. ' + + 'Vous pouvez dépenser en toute sûreté {ss}/an jusqu\'à {le} ans (vous prévoyez {sp}). Les trois cartes ci-dessous comblent l\'écart — chacune, à elle seule.', + good: 'Ce plan tient jusqu\'à {le} ans avec {bal} restants — vous pouvez soutenir environ {ss}/an. ' + + 'Cela fait {hr}/an de marge au-dessus de votre plan.', + yearlySummary: 'Détail année par année ({n} ans)', + whatif: 'Essayer un scénario — déplacez un curseur, regardez le plan se mettre à jour', + stamp: 'barèmes 2026 · max. RPC {cpp}/mois · max. SV {oas}/mois · max. SRG {gis}/mois · constantes au {asOf} · table FERR Règl. 8301(1) ARC', + wiSpend: 'Dépenses annuelles', wiCtrb: 'Épargne mensuelle', wiRet: 'Âge de la retraite', wiCpp: 'La RPC commence à', + wiYears: '{v} ans', wiAge: 'à {v} ans' + }, + g: { + gapH: 'L\'écart', gapP: 'entre vos dépenses prévues et ce que le plan peut financer de façon sûre.', + saveH: '💪 Épargner plus', + saveP: 'de REER en plus dès aujourd\'hui comble l\'écart à lui seul. ~{pct} du revenu.', + saveP2: 'Impossible à combler par l\'épargne seule — voyez les deux autres leviers.', + workH: '🕰 Travailler plus', workY1: '{n} an', workYN: '{n} ans', + workP: 'partir à {at} ans au lieu de {now} comble l\'écart — et RPC/SV grossissent pendant ce temps.', + workP2: 'En 10 ans de plus, retarder seul la retraite ne comble pas cet écart — combinez les leviers.', + trimH: '🌿 Réduire les dépenses', trimP: 'à {to}. Le levier le moins coûteux, et celui dont personne ne parle à 45 ans.', + statusH: '✅ Statut', onTrack: 'Sur la bonne voie', + statusP: 'Aucun écart à {sp}/an. Essayez un chiffre plus élevé avec les curseurs « et si ».', + headH: 'Marge', headP: 'que vous pourriez ajouter aux dépenses — ou épargner pour le chalet.', + stressH: '🛡 Test de résistance', stressP: 'dépenses réelles durables jusqu\'à {age} ans, minimums FERR et impôts respectés.' + }, + t: { + age: 'Âge', rrsp: 'REER', tfsa: 'CELI', nonreg: 'Non enreg.', total: 'Total', wdr: 'Retrait', + cpp: 'RPC', oas: 'SV', gis: 'SRG', tax: 'Impôt + récup.', unfunded: 'Non financé' + }, + d: { + main: 'Estimations seulement — il ne s\'agit pas de conseils financiers. Modèle simplifié : retrait progressif simplifié de 50 % pour la SRG; Québec présenté avec le dégagement fédéral de 16,5 % (RRQ et cotisations sociales québécoises non modélisés); pensions à prestations non modélisées; rendements à taux réels constants; surtaxe de l\'Ontario calculée sur l\'impôt de base.', + privacy: '🔒 100 % côté client — vos réponses ne quittent jamais votre navigateur. Vos réponses ne sont enregistrées que dans ce navigateur (localStorage).' + }, + res: { + print: '🖨 Imprimer / enregistrer en PDF', + share: '🔗 Copier le lien avec mes réponses', + edit: '✎ Modifier les réponses' + }, + share: { + copied: '✅ Lien copié', + shown: '🔗 Lien affiché — copiez-le', + prompt: 'Copiez votre lien :' + }, + restart: { confirm: 'Recommencer ? Les réponses enregistrées dans ce navigateur seront effacées.' }, + vision: { + alt: 'Votre vision de retraite', + label: 'Vision de retraite — {slug}', + ai: 'Aperçu généré par IA (z-image-turbo). Vos chiffres : ci-dessous.' + } + }; + + /* French typography pass: ' between letters, no-break space before : ; ! ? % + and inside « » guillemets. Applied to every fr leaf at load. */ + function frNorm(s) { + return s + .replace(/(\p{L})'(\p{L})/gu, '$1\u2019$2') + .replace(/« /g, '\u00ab' + NB) + .replace(/ »/g, NB + '\u00bb') + .replace(/ ([:;!?%])/g, NB + '$1'); + } + (function walkFr(o) { + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) { + if (typeof o[k] === 'string') o[k] = frNorm(o[k]); + else if (o[k] && typeof o[k] === 'object') walkFr(o[k]); + } + })(FR); + + var I18N = { en: EN, fr: FR }; + + /* ---------- language state ---------- */ + var lang = 'en'; + + function detectLang() { + var n = (typeof navigator !== 'undefined' && navigator.language) || 'en'; + return String(n).toLowerCase().indexOf('fr') === 0 ? 'fr' : 'en'; + } + /* payload lang > saved state lang > detectLang(); old payloads (no lang) → en */ + function initialLang() { + var m = typeof location !== 'undefined' && location.hash ? location.hash.match(/[#&]p=([^&]+)/) : null; + if (m) { + var o = null; + try { o = JSON.parse(decodeURIComponent(escape(atob(m[1])))); } catch (e) { o = null; } + if (o && typeof o === 'object') return o.lang === 'fr' ? 'fr' : 'en'; + } + try { + var raw = localStorage.getItem(LS_KEY); + if (raw) { + var s = JSON.parse(raw); + if (s && (s.lang === 'en' || s.lang === 'fr')) return s.lang; + } + } catch (e) { /* fresh */ } + return detectLang(); + } + function getLang() { return lang; } + /* merge lang into the persisted state object, keeping {a, step, screen, lang} shape */ + function persistLang() { + try { + var o = {}; + var raw = localStorage.getItem(LS_KEY); + if (raw) { var p = JSON.parse(raw); if (p && typeof p === 'object') o = p; } + o.lang = lang; + localStorage.setItem(LS_KEY, JSON.stringify(o)); + } catch (e) { /* private mode etc. — language just won't persist */ } + } + function setLang(l) { + l = (l === 'fr') ? 'fr' : 'en'; + if (l === lang) return; + lang = l; + persistLang(); + applyI18n(); + if (typeof window !== 'undefined' && typeof window.__onLangChange === 'function') { + try { window.__onLangChange(); } catch (e) { /* re-render hook must not break toggle */ } + } + } + + /* ---------- t() ---------- */ + function lookup(obj, key) { + var p = key.split('.'), o = obj; + for (var i = 0; i < p.length; i++) { + if (o === null || typeof o !== 'object') return null; + o = o[p[i]]; + } + return (typeof o === 'string') ? o : null; + } + function t(key, vars) { + var s = lookup(I18N[lang], key); + if (s === null) s = lookup(I18N.en, key); + if (s === null) return key; + if (vars) s = s.replace(/\{(\w+)\}/g, function (m0, k) { return vars[k] != null ? String(vars[k]) : m0; }); + return s; + } + + /* ---------- locale-aware formatters (Intl.NumberFormat) ---------- */ + var nfCache = {}; + function nf(kind, opts) { + var key = kind + '|' + lang; + if (!nfCache[key]) nfCache[key] = new Intl.NumberFormat(lang === 'fr' ? 'fr-CA' : 'en-CA', opts); + return nfCache[key]; + } + function fmtInt(n) { return nf('int', { maximumFractionDigits: 0 }).format(n); } + function fmtMoney(n) { + n = Math.round(n); + return (n < 0 ? MINUS : '') + nf('c0', { style: 'currency', currency: 'CAD', maximumFractionDigits: 0 }).format(Math.abs(n)); + } + function fmtMoney2(n) { + n = +n; + return (n < 0 ? MINUS : '') + nf('c2', { style: 'currency', currency: 'CAD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Math.abs(n)); + } + function fmtPct(x, digits) { + var d = digits == null ? 0 : digits; + return nf('p' + d, { style: 'percent', maximumFractionDigits: d }).format(x); + } + /* compact currency, ≤3 significant digits: $523K / $1.05M — 523 k$ / 1,05 M$ */ + function fmtMoneyK(n) { + n = Math.round(n); + var s = n < 0 ? MINUS : ''; + n = Math.abs(n); + var num, unit; + if (n >= 1e6) { num = sig3(n / 1e6); unit = 'M'; } + else if (n >= 1000) { num = String(Math.round(n / 1000)); unit = 'K'; } + else { num = String(n); unit = ''; } + if (lang === 'fr') { + num = nf('kd', { maximumFractionDigits: 2 }).format(Number(num)); /* 1.05 → 1,05 */ + unit = unit === 'K' ? 'k' : unit; + return s + num + NNB + (unit || '') + '$'; + } + return s + '$' + num + unit; + } + function sig3(x) { + var d = x >= 100 ? 0 : (x >= 10 ? 1 : 2); + var s = x.toFixed(d); + if (s.indexOf('.') >= 0) s = s.replace(/0+$/, '').replace(/\.$/, ''); + return s; + } + + /* ---------- static DOM ---------- */ + function forEachEl(sel, fn) { + if (!document.querySelectorAll) return; + var nodes = document.querySelectorAll(sel); + for (var i = 0; i < nodes.length; i++) fn(nodes[i]); + } + function applyI18n() { + var de = document.documentElement; + if (de) { if (de.setAttribute) de.setAttribute('lang', lang); de.lang = lang; } + document.title = t('doc.title'); + forEachEl('[data-i18n]', function (el) { el.textContent = t(el.getAttribute('data-i18n')); }); + forEachEl('[data-i18n-html]', function (el) { el.innerHTML = t(el.getAttribute('data-i18n-html')); }); + forEachEl('[data-i18n-attr]', function (el) { + var spec = el.getAttribute('data-i18n-attr') || ''; + spec.split(',').forEach(function (pair) { + var i = pair.indexOf(':'); + if (i < 0) return; + el.setAttribute(pair.slice(0, i).trim(), t(pair.slice(i + 1).trim())); + }); + }); + var be = document.getElementById('lang-en'), bf = document.getElementById('lang-fr'); + if (be) be.setAttribute('aria-pressed', lang === 'en' ? 'true' : 'false'); + if (bf) bf.setAttribute('aria-pressed', lang === 'fr' ? 'true' : 'false'); + } + function wireToggle() { + var be = document.getElementById('lang-en'), bf = document.getElementById('lang-fr'); + if (be) be.onclick = function () { setLang('en'); }; + if (bf) bf.onclick = function () { setLang('fr'); }; + } + + /* ---------- expose (browser window / Node globalThis in the test shims) ---------- */ + var exp = { + I18N: I18N, t: t, getLang: getLang, setLang: setLang, detectLang: detectLang, + applyI18n: applyI18n, fmtMoney: fmtMoney, fmtMoneyK: fmtMoneyK, fmtMoney2: fmtMoney2, + fmtInt: fmtInt, fmtPct: fmtPct + }; + lang = initialLang(); + try { if (typeof window !== 'undefined') for (var k in exp) window[k] = exp[k]; } catch (e) { } + try { for (var k2 in exp) globalThis[k2] = exp[k2]; } catch (e) { } + + if (typeof document !== 'undefined' && document.querySelectorAll) { + applyI18n(); + wireToggle(); + } +})(); diff --git a/site/index.html b/site/index.html index de71a95..c820830 100644 --- a/site/index.html +++ b/site/index.html @@ -6,32 +6,35 @@ Retire Planner — Canadian retirement, modelled properly - +
- - 🔒 100% client-side — answers never leave your browser - + + + + + 🔒 100% client-side — answers never leave your browser +
-

Will you actually run out of money?

-

Most Canadian retirement calculators stop at “your RRSP grows at 6%”. This one models real CPP/OAS/GIS/RRIF mechanics, provincial taxes fetched live, and the tax cliff most planners won't show you.

+

Will you actually run out of money?

+

Most Canadian retirement calculators stop at “your RRSP grows at 6%”. This one models real CPP/OAS/GIS/RRIF mechanics, provincial taxes fetched live, and the tax cliff most planners won't show you.

    -
  • CPP early/late (−0.6%/mo before 65, +0.7% after), OAS deferral, clawback & GIS
  • -
  • Live 2026 federal + all 13 province/territory tax tables
  • -
  • RRIF minimums at 71, pension splitting, tax-smart drawdown
  • -
  • Couples: joint taxes, split pensions, household GIS
  • +
  • CPP early/late (−0.6%/mo before 65, +0.7% after), OAS deferral, clawback & GIS
  • +
  • Live 2026 federal + all 13 province/territory tax tables
  • +
  • RRIF minimums at 71, pension splitting, tax-smart drawdown
  • +
  • Couples: joint taxes, split pensions, household GIS
-

🔒 100% client-side — answers never leave your browser. No account, no tracking, nothing is sent anywhere.

- -

~2 minutes. Works on one screen at a time; you can change anything later.

+

🔒 100% client-side — answers never leave your browser. No account, no tracking, nothing is sent anywhere.

+ +

~2 minutes. Works on one screen at a time; you can change anything later.

@@ -40,14 +43,14 @@
- +
- +
@@ -59,7 +62,7 @@ @@ -105,6 +108,7 @@ + diff --git a/site/style.css b/site/style.css index ff53476..d5d773d 100644 --- a/site/style.css +++ b/site/style.css @@ -23,6 +23,11 @@ b{color:var(--ink)} .hdr-in{max-width:1060px;margin:0 auto;display:flex;align-items:center;gap:10px;padding:10px 12px;flex-wrap:wrap} .logo{font-size:17px} .pill{font-size:12px;background:#e8f5ee;color:#0a5c36;border:1px solid #bfe6d0;border-radius:99px;padding:3px 10px;color:#0a5c36} +/* language toggle: compact EN|FR pill group; 44px touch targets (mobile pass rules) */ +.lang-toggle{display:inline-flex;border:1px solid var(--line);border-radius:99px;overflow:hidden;flex:none;background:var(--card)} +.lang-btn{padding:4px 10px;border:0;background:transparent;font-size:13px;font-weight:700;color:var(--ink2);cursor:pointer;min-height:44px;line-height:1.2} +.lang-btn[aria-pressed="true"]{background:var(--blue);color:#fff} +.logo{font-size:17px;min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .ghost{background:none;border:1px solid var(--line);border-radius:10px;padding:7px 12px;font-size:14px;color:var(--ink2);cursor:pointer;min-height:44px} button,input{touch-action:manipulation} .ghost:hover{border-color:var(--blue);color:var(--blue)} diff --git a/tests/i18n.test.js b/tests/i18n.test.js new file mode 100644 index 0000000..d0a71e8 --- /dev/null +++ b/tests/i18n.test.js @@ -0,0 +1,175 @@ +// i18n unit tests: dict parity, t() semantics, Intl formatters (EN/FR), +// static applyI18n over the real index.html (parsed shim), lang persistence + load precedence. +// Run: node tests/i18n.test.js (exits 0 + prints I18N-OK) +'use strict'; +const fs = require('fs'), path = require('path'); +const SITE = path.join(__dirname, '..', 'site'); +const LS = 'retire-planner-v1'; +let fails = 0, total = 0; +function ok(name, cond, extra) { + total++; + console.log((cond ? ' PASS ' : ' FAIL ') + name + (extra !== undefined ? ' [' + String(extra).slice(0, 90) + ']' : '')); + if (!cond) fails++; +} +const N = s => String(s == null ? '' : s).replace(/[\u202f\u00a0]/g, ' '); // narrow-nbsp/nbsp → space + +/* ---------- shim: minimal document that mirrors the real index.html static attrs ---------- */ +function parseStatic(html) { + const els = { text: [], html: [], attr: [] }; + let m; const reT = /<(\w+)[^>]*\sdata-i18n="([^"]+)"[^>]*>/g; + while ((m = reT.exec(html))) els.text.push(mk(m[2], 'data-i18n')); + const reH = /<(\w+)[^>]*\sdata-i18n-html="([^"]+)"[^>]*>/g; + while ((m = reH.exec(html))) els.html.push(mk(m[2], 'data-i18n-html')); + const reA = /<(\w+)[^>]*\sdata-i18n-attr="([^"]+)"[^>]*>/g; + while ((m = reA.exec(html))) els.attr.push(mk(m[2], 'data-i18n-attr')); + return els; +} +function mk(v, attr) { + const attrs = {}; attrs[attr] = v; + return { + attrs, textContent: '', innerHTML: '', + getAttribute(k) { return attrs[k] === undefined ? null : attrs[k]; }, + setAttribute(k, v2) { attrs[k] = String(v2); }, + }; +} +function fresh(opts) { + opts = opts || {}; + const els = parseStatic(fs.readFileSync(path.join(SITE, 'index.html'), 'utf8')); + const store = Object.assign({}, opts.store); + const langBtns = { 'lang-en': mk('', 'id'), 'lang-fr': mk('', 'id') }; + global.document = { + title: '', + documentElement: { lang: 'en', setAttribute() { } }, + querySelector: () => null, + querySelectorAll(sel) { + if (sel === '[data-i18n]') return els.text; + if (sel === '[data-i18n-html]') return els.html; + if (sel === '[data-i18n-attr]') return els.attr; + return []; + }, + getElementById: id => langBtns[id] || null, + createElement: () => mk('', 'tmp'), + addEventListener() { }, + }; + global.localStorage = { getItem: k => (store[k] === undefined ? null : store[k]), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }; + global.location = { hash: opts.hash || '', pathname: '/index.html', origin: 'http://x' }; + global.history = { replaceState() { } }; + Object.defineProperty(global, 'navigator', { value: { language: opts.nav || 'en-CA' }, configurable: true }); + global.window = { scrollTo() { }, print() { }, prompt() { } }; + global.confirm = () => true; + global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + eval(fs.readFileSync(path.join(SITE, 'i18n.js'), 'utf8')); + return { els, store, langBtns }; +} + +/* ---------- 1. load + dict parity ---------- */ +let ctx = fresh(); +const { I18N, t, getLang, setLang, detectLang, applyI18n, fmtMoney, fmtMoneyK, fmtInt, fmtPct, fmtMoney2 } = global.window; + +function leafKeys(obj, prefix, out) { + for (const k in obj) { + const v = obj[k], key = prefix ? prefix + '.' + k : k; + if (v && typeof v === 'object') leafKeys(v, key, out); else out.push(key); + } + return out; +} +const enK = leafKeys(I18N.en, '', []), frK = leafKeys(I18N.fr, '', []); +const enSet = new Set(enK), frSet = new Set(frK); +ok('en/fr key parity', enK.every(k => frSet.has(k)) && frK.every(k => enSet.has(k)), + 'missing in fr: ' + enK.filter(k => !frSet.has(k)) + ' | extra in fr: ' + frK.filter(k => !enSet.has(k))); +const get = (o, key) => key.split('.').reduce((x, p) => (x && typeof x === 'object' ? x[p] : undefined), o); +const empty = enK.filter(k => !get(I18N.en, k).trim()).concat(frK.filter(k => !get(I18N.fr, k).trim())); +ok('no empty values', empty.length === 0, empty.join(',')); +const ph = s => (s.match(/\{\w+\}/g) || []).slice().sort().join(','); +const phBad = enK.filter(k => frSet.has(k) && ph(get(I18N.en, k)) !== ph(get(I18N.fr, k))); +ok('{placeholder} sets equal en↔fr', phBad.length === 0, phBad.map(k => k + ': ' + ph(get(I18N.en, k)) + ' vs ' + ph(get(I18N.fr, k))).join(' | ')); + +/* every key used in app.js exists in the dict */ +const appSrc = fs.readFileSync(path.join(SITE, 'app.js'), 'utf8'); +const used = [...new Set([...appSrc.matchAll(/\bt\(\s*'([^']+)'/g)].map(m => m[1]))]; +const miss = used.filter(k => !enSet.has(k)); +ok('every t() key in app.js exists (en)', miss.length === 0, miss.join(',')); +const missFr = used.filter(k => !frSet.has(k)); +ok('every t() key in app.js exists (fr)', missFr.length === 0, missFr.join(',')); +ok('app.js uses t() extensively', used.length > 90, used.length + ' keys'); + +/* every static data-i18n key in index.html resolves */ +const staticKeys = [...ctx.els.text.map(e => e.getAttribute('data-i18n')), + ...ctx.els.html.map(e => e.getAttribute('data-i18n-html')), + ...ctx.els.attr.flatMap(e => e.getAttribute('data-i18n-attr').split(',').map(p => p.split(':')[1].trim()))]; +const sMiss = staticKeys.filter(k => t(k) === k); +ok('static keys resolve in en', sMiss.length === 0, sMiss.join(',')); + +/* ---------- 2. t() semantics ---------- */ +ok('getLang default en', getLang() === 'en'); +ok('t interp', t('q.cppstart.hint', { age: 70, adj: 'Full rate' }) === 'Age 70: Full rate'); +ok('t missing key falls back to key', t('nope.nope') === 'nope.nope'); + +/* ---------- 3. formatters EN ---------- */ +ok('fmtMoney en', fmtMoney(959835) === '$959,835', fmtMoney(959835)); +ok('fmtMoney en negative', fmtMoney(-32962) === '\u2212$32,962', fmtMoney(-32962)); +ok('fmtMoneyK en K', fmtMoneyK(523000) === '$523K', fmtMoneyK(523000)); +ok('fmtMoneyK en M', fmtMoneyK(1050000) === '$1.05M', fmtMoneyK(1050000)); +ok('fmtMoneyK en rounds', fmtMoneyK(959835) === '$960K', fmtMoneyK(959835)); +ok('fmtMoneyK en zero', fmtMoneyK(0) === '$0', fmtMoneyK(0)); +ok('fmtInt en', fmtInt(959835) === '959,835', fmtInt(959835)); +ok('fmtPct en', N(fmtPct(0.05)) === '5%' && N(fmtPct(0.035, 2)) === '3.5%', N(fmtPct(0.035, 2))); +ok('fmtMoney2 en', fmtMoney2(1507.65) === '$1,507.65', fmtMoney2(1507.65)); + +/* ---------- 4. formatters FR (setLang) ---------- */ +setLang('fr'); +ok('setLang fr', getLang() === 'fr'); +ok('t fr', t('quiz.next') === 'Suivant \u2192', t('quiz.next')); +ok('fmtMoney fr', N(fmtMoney(959835)) === '959 835 $', N(fmtMoney(959835))); +ok('fmtMoney fr negative', N(fmtMoney(-32962)) === '\u221232 962 $', N(fmtMoney(-32962))); +ok('fmtMoneyK fr K', N(fmtMoneyK(523000)) === '523 k$', N(fmtMoneyK(523000))); +ok('fmtMoneyK fr M', N(fmtMoneyK(1050000)) === '1,05 M$', N(fmtMoneyK(1050000))); +ok('fmtMoneyK fr zero', N(fmtMoneyK(0)) === '0 $', N(fmtMoneyK(0))); +ok('fmtInt fr', N(fmtInt(959835)) === '959 835', N(fmtInt(959835))); +ok('fmtPct fr', N(fmtPct(0.05)) === '5 %' && N(fmtPct(0.035, 2)) === '3,5 %', N(fmtPct(0.035, 2))); +ok('fmtMoney2 fr', N(fmtMoney2(1507.65)) === '1 507,65 $', N(fmtMoney2(1507.65))); +ok('t fr interp', t('q.retire.years', { v: 20 }) === '20 ans', t('q.retire.years', { v: 20 })); + +/* ---------- 5. French typography (frNorm) ---------- */ +ok('fr apostrophe is U+2019', /d\u2019argent/.test(I18N.fr.r.depletion), I18N.fr.r.depletion.slice(0, 40)); +ok('fr NBSP before %', /6\u00a0%/.test(I18N.fr.intro.lede), N(I18N.fr.intro.lede).slice(0, 40)); +ok('fr guillemets NBSP', /\u00ab\u00a0\{name\}\u00a0\u00bb/.test(I18N.fr.g4.best), N(I18N.fr.g4.best)); +ok('fr NBSP before ?', /\u00a0\?/.test(I18N.fr.g1.h), N(I18N.fr.g1.h)); + +/* ---------- 6. static applyI18n (FR active) ---------- */ +ok('title fr', /Planificateur de retraite/.test(document.title), document.title); +const h1 = ctx.els.text.find(e => e.getAttribute('data-i18n') === 'intro.h1'); +ok('static h1 fr', h1 && h1.textContent === I18N.fr.intro.h1, h1 && N(h1.textContent)); +const desc = ctx.els.attr.find(e => /content:doc\.desc/.test(e.getAttribute('data-i18n-attr'))); +ok('meta description fr', desc && desc.attrs.content === I18N.fr.doc.desc); +ok('documentElement.lang fr', document.documentElement.lang === 'fr'); +ok('aria-pressed synced', ctx.langBtns['lang-en'].attrs['aria-pressed'] === 'false' && ctx.langBtns['lang-fr'].attrs['aria-pressed'] === 'true'); +setLang('en'); +ok('static h1 back to en', h1 && h1.textContent === I18N.en.intro.h1 && document.title.startsWith('Retire Planner')); + +/* ---------- 7. persistence + load precedence ---------- */ +setLang('fr'); +ok('setLang persists lang into LS merge', JSON.parse(ctx.store[LS]).lang === 'fr'); +ctx.store[LS] = JSON.stringify({ a: { age: 50 }, step: 3, screen: 'results' }); +setLang('en'); setLang('fr'); +const merged = JSON.parse(ctx.store[LS]); +ok('merge keeps a/step/screen', merged.a.age === 50 && merged.step === 3 && merged.screen === 'results' && merged.lang === 'fr', JSON.stringify(merged)); + +function b64(o) { return Buffer.from(JSON.stringify(o)).toString('base64'); } +/* NB: fresh() re-evals i18n.js → new closure instance; read exports off global.window each time */ +const G = () => global.window; +let c2 = fresh({ nav: 'fr-CA' }); +ok('detectLang fr-CA → fr', G().detectLang() === 'fr' && G().getLang() === 'fr', G().getLang()); +c2 = fresh({ nav: 'en-CA' }); +ok('detectLang en-CA → en', G().getLang() === 'en'); +c2 = fresh({ store: { [LS]: JSON.stringify({ lang: 'fr' }) }, nav: 'en-CA' }); +ok('saved lang beats detection', G().getLang() === 'fr', G().getLang()); +c2 = fresh({ store: { [LS]: JSON.stringify({ lang: 'en' }) }, nav: 'fr-CA', hash: '#p=' + b64({ a: {}, step: 0, lang: 'fr' }) }); +ok('payload lang beats saved + detect', G().getLang() === 'fr', G().getLang()); +c2 = fresh({ store: { [LS]: JSON.stringify({ lang: 'fr' }) }, hash: '#p=' + b64({ a: {}, step: 0 }) }); +ok('legacy payload (no lang) → en', G().getLang() === 'en', G().getLang()); +c2 = fresh({ store: { [LS]: JSON.stringify({ lang: 'fr' }) }, hash: '#p=' + b64({ a: {}, step: 0, lang: 'en' }) }); +ok('payload en beats saved fr', G().getLang() === 'en', G().getLang()); + +console.log(fails ? '\nI18N-FAIL: ' + fails + ' of ' + total : '\nI18N-OK all i18n checks passed (' + total + ')'); +process.exit(fails ? 1 : 0); diff --git a/tests/ui.smoke.js b/tests/ui.smoke.js index 4d16607..fb918b9 100644 --- a/tests/ui.smoke.js +++ b/tests/ui.smoke.js @@ -1,10 +1,15 @@ -// Headless smoke test for site/app.js — tiny DOM shim, drives the full flow. +// Headless smoke test for site/i18n.js + site/app.js — tiny DOM shim, drives the full flow +// in BOTH languages (EN run on defaults, FR run with lang:'fr' persisted). // Run: node tests/ui.smoke.js (exits 0 + prints SMOKE-OK) 'use strict'; const fs = require('fs'), path = require('path'); const SITE = path.join(__dirname, '..', 'site'); +const LS = 'retire-planner-v1'; -const registry = {}; +/* normalize narrow-nbsp/nbsp → space (fr-CA Intl + frNorm typography) for regex checks */ +const N = s => String(s == null ? '' : s).replace(/[\u202f\u00a0]/g, ' '); + +let registry = {}; function makeEl(id) { const el = { id, _html: '', _l: {}, style: {}, dataset: {}, hidden: false, @@ -12,6 +17,7 @@ function makeEl(id) { get innerHTML() { return this._html; }, set innerHTML(v) { this._html = String(v); }, addEventListener(t, fn) { this._l[t] = fn; }, + setAttribute() { }, getAttribute() { return null; }, querySelector() { return makeEl(id + '>q'); }, querySelectorAll() { return []; }, contains() { return false; }, @@ -28,83 +34,148 @@ function q(sel) { if (sel === '.panel-h') return null; return registry[id] || (registry[id] = makeEl(id)); } -global.document = { - querySelector: q, - querySelectorAll: () => [], - addEventListener() { }, - createElement: (t) => makeEl('el-' + t), - getElementById: (id) => registry[id] || (registry[id] = makeEl(id)), // shim ext: app.js scrim/panel sync (Fix 3) -}; -const store = {}; -global.localStorage = { getItem: k => store[k] || null, setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }; -global.location = { hash: '', pathname: '/index.html', origin: 'http://x' }; -global.history = { replaceState() { } }; -Object.defineProperty(global, 'navigator', { value: {}, configurable: true }); -global.window = { scrollTo() { }, print() { }, prompt() { } }; // prompt stub: Fix 7 share fallback -global.confirm = () => true; -global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); +let store = {}; +let createdEls = []; // shim ext: createElement tracking (validate() appends its .err div this way) +function buildShim() { + registry = {}; store = {}; createdEls = []; + global.document = { + title: '', + querySelector: q, + querySelectorAll: () => [], + addEventListener() { }, + createElement: (t) => { const e = makeEl('el-' + t); createdEls.push(e); return e; }, + getElementById: (id) => registry[id] || (registry[id] = makeEl(id)), // shim ext: app.js scrim/panel sync (Fix 3) + i18n lang buttons + documentElement: { style: { setProperty() { } }, setAttribute() { }, lang: 'en' }, // i18n applyI18n + app syncSheetH + }; + global.localStorage = { getItem: k => store[k] || null, setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }; + global.location = { hash: '', pathname: '/index.html', origin: 'http://x' }; + global.history = { replaceState() { } }; + Object.defineProperty(global, 'navigator', { value: { language: 'en-CA' }, configurable: true }); + global.window = { scrollTo() { }, print() { }, prompt() { } }; // prompt stub: Fix 7 share fallback + global.confirm = () => true; + global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); +} +function load(lang) { + buildShim(); + global.window.RetireEngine = require(path.join(SITE, 'engine', 'engine.js')); + global.window.RetireCanada = require(path.join(SITE, 'engine', 'canada.js')); + // persisted state in the target language: fresh answers, dropped at the quiz start + store[LS] = JSON.stringify({ a: {}, step: 0, screen: 'quiz', lang: lang }); + eval(fs.readFileSync(path.join(SITE, 'i18n.js'), 'utf8')); + eval(fs.readFileSync(path.join(SITE, 'app.js'), 'utf8')); +} -// load engine + canada + app in order (classic scripts sharing globals) -const engP = path.join(SITE, 'engine', 'engine.js'), canP = path.join(SITE, 'engine', 'canada.js'), appP = path.join(SITE, 'app.js'); -global.window.RetireEngine = require(engP); -global.window.RetireCanada = require(canP); -eval(fs.readFileSync(appP, 'utf8')); - -// ---- drive the UI ---- -let fails = 0; +let fails = 0, total = 0; function ok(name, cond, extra) { + total++; console.log((cond ? ' PASS ' : ' FAIL ') + name + (extra !== undefined ? ' [' + String(extra).slice(0, 80) + ']' : '')); if (!cond) fails++; } const click = id => { const el = q('#' + id); if (!el._l.click) throw new Error('no click handler on #' + id); el._l.click(); }; -ok('intro wiring: begin button has handler', !!q('#btn-begin')._l.click); -click('btn-begin'); -ok('quiz screen shown, step 1 = province', /Where do you live/.test(q('#step-body')._html)); +/* drive the full quiz → results flow for one language, then run per-language checks. */ +function run(lang, M, done) { + console.log('\n--- ' + lang.toUpperCase() + ' ---'); + load(lang); -// province grid: app attaches onclick to opts via querySelectorAll (shim returns []) -// → set province through state by faking a click on the first opt is impossible; -// simulate by driving Next: province unset → Next disabled. -ok('province step requires a pick (Next disabled)', q('#btn-next').disabled === true); -// emulate the option click path: app.js assigns onclick to elements from querySelectorAll; -// give it one opt for the current render: -const opt = makeEl('opt'); opt.dataset.v = 'ON'; q('#step-body').querySelectorAll = (sel) => (sel === '.opt' ? [opt] : []); -click('btn-begin'); // re-render step with patched querySelectorAll -opt._l.click(); -ok('province ON selectable', registry.__probe !== 'x' && !/Pick a province/.test(q('#step-body')._html)); + ok('intro wiring: begin button has handler', !!q('#btn-begin')._l.click); + click('btn-begin'); + ok('step 1 title', M.title.test(q('#step-body')._html), N(q('#step-body')._html).slice(0, 60)); + ok('step count', /1 \/ 14/.test(q('#step-count').textContent), q('#step-count').textContent); + ok('province step requires a pick (Next disabled)', q('#btn-next').disabled === true); + ok('validation message', createdEls.some(e => M.needProvince.test(N(e.textContent)))); -// walk all steps -let guard = 0; -while (!q('#btn-next').disabled && guard++ < 40) { - const h = q('#step-body')._html; - if (/runs out|gap|vision/i.test(h) && /See my plan/.test(q('#btn-next').textContent)) break; + // emulate the option click path (shim querySelectorAll patched to hand back one opt) + const opt = makeEl('opt'); opt.dataset.v = 'ON'; + q('#step-body').querySelectorAll = (sel) => (sel === '.opt' ? [opt] : []); + click('btn-begin'); // re-render step with patched querySelectorAll + opt._l.click(); + ok('province ON selectable', q('#btn-next').disabled === false); + + // walk all steps (original gating: disabled reflects step validity) + let guard = 0, sawCpp = false, sawSeePlan = false; + while (guard++ < 40 && !q('#btn-next').disabled) { + const h = N(q('#step-body')._html); + if (M.cppStep.test(h)) sawCpp = true; + if (M.seePlan.test(q('#btn-next').textContent)) { sawSeePlan = true; break; } + click('btn-next'); + if (q('#scr-results').hidden === false) break; + } + ok('reached last step (See my plan / Voir mon plan)', sawSeePlan, 'steps=' + guard); + ok('CPP start step seen', sawCpp); click('btn-next'); - if (/Retirement Vision|vision/i.test(q('#scr-results')._html)) break; - if (q('#scr-intro').hidden === false) break; - // results render flips hidden - if (q('#scr-results').hidden === false) break; -} -ok('reached results screen', q('#scr-results').hidden === false, 'steps=' + guard); -ok('verdict rendered', /money|sustain|runs out/i.test(q('#verdict')._html)); -ok('gap cards rendered', /gcard/.test(q('#gap-cards')._html)); -ok('G1 balance chart is inline SVG', / 20); -ok('stamp has 2026 + CPP max', /tax rates 2026/.test(q('#stamp').textContent) && /1,507\.65/.test(q('#stamp').textContent), q('#stamp').textContent.slice(0, 60)); -ok('vision card rendered', /Retirement Vision/.test(q('#vision-card')._html)); -ok('privacy line present in HTML', /never leave your browser/.test(fs.readFileSync(path.join(SITE, 'index.html'), 'utf8'))); + ok('reached results screen', q('#scr-results').hidden === false); -// G4 async (setTimeout 30ms) -setTimeout(() => { - ok('G4 scenario bars rendered', / 20); + ok('year table header localized', M.tblHeader.test(q('#yr-table')._html)); + ok('year summary localized', M.yrSummary.test(N(q('#yr-summary')._html)), N(q('#yr-summary')._html)); + const stamp = N(q('#stamp').textContent); + ok('stamp has 2026 + CPP max (locale format)', M.stamp2026.test(stamp) && M.stampCpp.test(stamp), stamp.slice(0, 80)); + ok('vision card rendered', M.vision.test(q('#vision-card')._html)); + ok('document.title localized', M.docTitle.test(N(document.title)), document.title.slice(0, 60)); + + // G4 async (setTimeout 30ms) + live panel (120ms) setTimeout(() => { - ok('what-if slider updates verdict (spending 95k)', /95/.test(q('#verdict')._html + q('#wi-spend-o').textContent), q('#wi-spend-o').textContent); - ok('localStorage saved', !!store['retire-planner-v1']); - console.log(fails ? '\nSMOKE-FAIL: ' + fails : '\nSMOKE-OK all ui smoke checks passed'); - process.exit(fails ? 1 : 0); - }, 500); -}, 250); + ok('G4 scenario bars rendered', / { + ok('what-if slider updates spending label (95k)', /95/.test(N(q('#wi-spend-o').textContent)), N(q('#wi-spend-o').textContent)); + ok('wi label localized', M.wiCtrb.test(N(q('#wi-ctrb-o').textContent)), N(q('#wi-ctrb-o').textContent)); + ok('localStorage saved with lang', JSON.parse(store[LS]).lang === lang); + done(); + }, 500); + }, 250); +} + +const EN = { + title: /Where do you live/, + docTitle: /Retire Planner/, + needProvince: /Pick a province/, + seePlan: /See my plan/, + cppStep: /Start CPP/, + verdict: /money|sustain|runs out/, + g1Title: /Will it last/, + g2Legend: /From savings/, + tblHeader: /Withdraw/, + yrSummary: /Year-by-year detail \(\d+ years\)/, + stamp2026: /tax rates 2026/, stampCpp: /1,507\.65/, + vision: /Retirement Vision/, + g4Scen: /Current plan/, + panel: /Savings at retirement/, + wiCtrb: /\/mo/, +}; +const FR = { + title: /Où habitez-vous/, + docTitle: /Planificateur de retraite/, + needProvince: /Choisissez une province/, + seePlan: /Voir mon plan/, + cppStep: /Débuter la RPC/, + verdict: /manquez d.argent|dépenser en toute sûreté|tient jusqu/, + g1Title: /épargne tiendra-t-elle/, + g2Legend: /Épargne/, + tblHeader: /Retrait/, + yrSummary: /Détail année par année \(\d+ ans\)/, + stamp2026: /barèmes 2026/, stampCpp: /1 507,65/, + vision: /Vision de retraite/, + g4Scen: /Plan actuel/, + panel: /Épargne à la retraite/, + wiCtrb: /\/mois/, +}; + +run('en', EN, () => run('fr', FR, () => { + ok('EN privacy line present in index.html', /never leave your browser/.test(fs.readFileSync(path.join(SITE, 'index.html'), 'utf8'))); + ok('FR privacy string present in i18n.js', /ne quittent jamais votre navigateur/.test(fs.readFileSync(path.join(SITE, 'i18n.js'), 'utf8'))); + console.log(fails ? '\nSMOKE-FAIL: ' + fails + ' of ' + total : '\nSMOKE-OK all ui smoke checks passed (' + total + ')'); + process.exit(fails ? 1 : 0); +}));