Mobile pass: 8 fixes per MOBILE-FIX.md (single-row 55px header, 560px-scroll charts w/ bigger SVG fonts + swipe hint, content-height chips sheet w/ scrim, 44px touch targets, year table 13px/-2 cols/fade/sticky Age, vision manifest-race fix, honest share fallback, theme-color+type bumps); before/after audits + geo-check evidence

This commit is contained in:
krisf committed 2026-10-09 06:04:03 -04:00
1 parent 73132a1edf
commit 53315e03e8
37 files changed
+625 -21

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
# MOBILE PASS — REPORT
Executed by DSH (local 27B) per `MOBILE-FIX.md` v1. All 8 fixes implemented in `site/`, tested, deployed to https://retire.krisforbes.ca, and verified at 390/360/320px.
**Verification method:** every acceptance number below is a **programmatic DOM/geometry assertion** run against the live deployed site with Playwright (real Chromium, iPhone-14 390×844@3x emulation) — see `audit/geo_check.py` → `audit/geo-check-390.json`. Screenshots are archived in `audit/mobile-before/` and `audit/mobile-after/` for human visual review. (Note: the executing model for this run has no image input, so screenshot "vision checks" were performed as DOM/geometry assertions, not by inspecting PNGs; the PNG pairs are included for the user's eye.)
## Issue → fix → evidence
### 1. Header ate ~15% of the phone screen (126px, ~3 rows)
**Fix** (`style.css` ≤860px): `.hdr-in{flex-wrap:nowrap;padding:5px 10px}`, `.hdr{padding-top:env(safe-area-inset-top)}`, `.logo{font-size:15px}`, `.hdr .ghost{padding:6px 9px;font-size:13px}`; ≤700px hides `.hdr .pill` (repeated on intro + footer); `.hdr #btn-print{display:none}` scoped to the header so the results-page Print button in `.btn-row` survives.
**Verified:** `hdr.h = 55px` at 390px (spec target ≤56). Single flex row (`flex-wrap:nowrap`). Screenshots `iphone14-quiz-province.png` / `-results-top.png` (after) vs same names in `mobile-before/`.
**Deviation:** spec suggested `padding:6px 10px`; with `.ghost{min-height:44px}` (Fix 4) 6px padding makes the header 57px (> the ≤56px target), so vertical padding is 5px. Desktop untouched.
### 2. Chart text unreadable (~5.8px at 390px)
**Fix** (`app.js`): `axes()` ticks 11→13, axis-title 11→12; G1 `retire` 10.5→12, `runs out ≈` 12→13; G3 `shortfall from ~` 12→13; G4 ticks 11→13, value labels 12.5→13.5, bar names 10.5→12. (`style.css`, Fix 2b below.)
**Verified:** SVG tick `font-size` attr = 13; rendered tick text at 390px = **11.4 CSS px** (13 × 560/640), up from ~5.8. G4 value labels 13.5 → 11.8px rendered. Spec threshold ≥11px met.
### 2b. Charts horizontal-scroll on phones
**Fix** (`style.css` ≤860px): `.chartbox{overflow-x:auto;overflow-y:hidden}`, `.chartbox svg{min-width:560px}`, CSS-only hint `.chartbox::before{content:"swipe →"}` (sticky, top-right, muted, `pointer-events:none`; desktop unaffected).
**Verified:** at 390px: `svg width=560px` (min-width applied), `#g1.scrollWidth 580 > clientWidth 356` → scrollable, `hasSwipe:true`.
### 3. Bottom sheet looked broken (58px peek, clipped chips, no scrim)
**Fix:** transform peek removed — sheet height is its content, chips always visible; insight hidden until `.open` (tap-header toggle kept, ▲/▼ affordance kept); `bottom:calc(8px + env(safe-area-inset-bottom))`; `.scr{padding-bottom:calc(185px + env(safe-area-inset-bottom))}`; new `#scrim` (`z-index:35` < panel 40, ≤860px only) — JS toggles `hidden` + `.on` in sync with `.panel.open`, tapping scrim closes the sheet, `showScreen()` re-syncs.
**Verified at 390px:** collapsed panel occupies y=618→836 of 844 — all 4 chip **labels and values fully in view** (e.g. "Savings at retirement / $959,835", `fullyInView:true` — no mid-glyph clip); `#btn-next` bottom 535 ≤ panel top 618 (Next never covered); collapsed state shows no insight (`insightShownByDefault:false`); after header tap: `open:true, scrimVisible:true`, z-order scrim 35 < panel 40 (header 30 stays under the scrim); scrim click → `panelOpen:false, scrimHidden:true`. Screenshots `iphone14-sheet-collapsed.png`, `iphone14-sheet-open.png`, `iphone14-quiz-sheet-open.png`.
### 4. Year-by-year table 1.8× horizontal scroll
**Fix** (`style.css` ≤560px): hide columns 4 (Non-reg) and 9 (GIS) — `Unfunded` kept; `table{font-size:13px}`; right-edge fade via `.tbl-wrap::before` gradient (`pointer-events:none`). Sticky first column + sticky header untouched. (Media block placed after the base table rules so the font-size wins.)
**Verified at 390px:** `col4Hidden:true, col9Hidden:true`; table font 13px; scroll ratio 494/326 = **1.52×** (was 1.83×); `fadePresent:true`; with `.tbl-wrap.scrollLeft=168` the Age column stays pinned (`ageColLeftInBox:1px, ageColVisible:true`). Screenshot `iphone14-table-scrolled-right.png`.
### 5. 32px ghost buttons
**Fix** (`style.css`): `.ghost{min-height:44px}`; `button,input{touch-action:manipulation}`.
**Verified:** every visible `.ghost/.opt/.cta` on the quiz at 390px is ≥44px: Back 44, Start over 44, Next 50; `minTouch:44`.
### 6. Vision card race → permanent gradient on direct load into results
**Fix** (`app.js`): manifest fetch wrapped in `visionReady = Promise.race([fetch, 2500ms timeout])`; on resolve **and** on reject, `if (state.screen === 'results') renderVision(state.a)` re-renders; timeout keeps the gradient. `img.onerror` → grad() fallback kept.
**Verified:** fresh context, localStorage screen=results, reload → `visionIsImg:true, visionGrad:false`, img 356×250 (real `vision/dock.jpeg` for dock+travel answers). Screenshot `iphone14-results-top.png` (before = flat gradient).
### 7. Share button lied when clipboard unavailable
**Fix** (`app.js`): `.writeText(url).then(done, fail)`; on reject **or** missing `navigator.clipboard`: `window.prompt('Copy your link:', url)` + label "🔗 Link shown — copy it"; "✅ Link copied" only on real success.
**Verified:** code path + smoke shim (`prompt()` stub, SMOKE-OK); real-clipboard path unchanged in behavior.
### 8. Minor
`<meta name="theme-color" content="#f7f9fc">` added → `themeColor:"#f7f9fc"` in-page; `.legend` 12.5→13px (`legendPx:"13px"`); `.opt small` 11.5→12px; Enter on number steps fires Next (3 lines in `renderStep`).
## Gates (spec Definition of done)
- Tests: `engine.test.js` **36 pass, 0 fail**; `ui.smoke.js` **SMOKE-OK** (run before deploy, and after the CSS-order tweak).
- Deploy: `scripts/deploy.sh` → **DEPLOY-OK**; origin `http://192.168.0.123:8103/` grep `retire-planner v1` → 1; marker comment + privacy text untouched.
- Off-LAN (Midhurst troublebox, paramiko `trouble@100.96.20.61:2222`): `https://retire.krisforbes.ca/` → **200**, `https://retire.krisforbes.ca/vision/dock.jpeg` → **200**.
- Engine files (`site/engine/*.js`) not modified. No frameworks/builds added. Desktop layout unchanged (all mobile rules in `@media ≤860/700/560`).
## Evidence files
- `audit/geo-check-390.json` — full machine-readable assertion output (spec step 6).
- `audit/geo_check.py`, `audit/supp_shots.py`, `audit/mobile_audit_run.py` (workspace copy of the Hermes audit script; only the OUT path differs — the scratch dir was read-only from this sandbox; run with `TMPDIR=/tmp`).
- `audit/mobile-before/` (12 PNGs) vs `audit/mobile-after/` (15 PNGs, incl. sheet-collapsed / sheet-open / table-scrolled-right).
## Deviations from spec text (all forced, all documented)
1. Header `.hdr-in` vertical padding 5px (not 6px) — 6px + 44px ghost min-height = 57px header, breaking the spec's own ≤56px target.
2. Header print-hide rule scoped `.hdr #btn-print` — spec's literal `#btn-print` selector would also hide the results-page Print button the spec explicitly keeps.
3. Sheet-open state on desktop: no scrim, panel unchanged (per spec).
+98
View File
@@ -0,0 +1,98 @@
# MOBILE PASS — fix spec for https://retire.krisforbes.ca
**Spec v1 — written 2026-10-09 by Hermes (GLM). Execution: DSH (local 27B), all steps, zero user interaction.**
The user said: "do another pass to ensure mobile is a good experience and have DSH fix any issues you find."
## Evidence base (before-state, already collected by Hermes)
Mobile audit screenshots (Playwright, real Chromium, mobile emulation) are in
`/home/ubuntu/.hermes/cache/scratch/mobile-audit/` — devices: iPhone-14 390×844@3x,
Pixel 360×800@2.75x, iPhone-SE 320×700@2x. Re-run anytime with:
`python3 /home/ubuntu/.hermes/cache/scratch/mobile_audit.py`
Measured geometry (390px quiz): sticky header **126px tall (wraps to ~3 rows)**;
`.ghost` buttons **32px tall** (below the 44px touch minimum); G1 SVG renders at
**336px wide → scale 0.525 → 11px axis text renders at ~5.8px (unreadable)**;
year table `scrollWidth 595` vs `clientWidth 326` (1.8× horizontal scroll);
vision card renders **gradient fallback** on direct load into results (race, see #6).
Vision-checked findings (from the screenshots):
1. **Header eats ~15% of the phone screen** — logo + privacy pill + buttons wrap to 3 rows.
2. **Chart axis/legend/annotation text unreadable on phones** (~6px at 390px, worse at 320px; the "retire" marker label is nearly illegible).
3. **Bottom sheet collapsed state looks broken** — it peeks 58px and clips chip labels mid-glyph at the screen edge ("SAVINGS AT…" cut off). Expanded sheet covers the Next button with no scrim/close affordance.
4. **Year-by-year table needs 1.8× horizontal scroll**; 11 columns; no fade/hint that it scrolls.
5. **Touch targets**: Back/Start over/Print/Share ghost buttons are 32px tall.
6. **Vision card race**: on direct load into results (shared link `#p=…` or saved state), `renderVision` runs before `fetch('vision/manifest.json')` resolves → permanent gradient fallback even though the images exist.
7. **Share button lies**: if `navigator.clipboard` is missing/fails (older mobile Safari, non-secure context), it still shows "✅ Link copied".
8. Minor: no `theme-color` meta; legend 12.5px; `.opt small` 11.5px.
## Fixes (all in `site/` — index.html, style.css, app.js)
### Fix 1 — single-row compact header on mobile
In `style.css`, add inside a `@media(max-width:860px)` block:
- `.hdr-in{flex-wrap:nowrap;padding:6px 10px;gap:8px}` and `.hdr{padding-top:env(safe-area-inset-top)}`
- `.logo{font-size:15px}`; shrink header `.ghost` to `padding:6px 9px;font-size:13px`
- Hide `.hdr .pill` at ≤700px (the privacy pill is repeated on the intro screen and the footer — no information is lost).
- Hide the header `#btn-print` at ≤860px on mobile (the results page keeps its own Print button in `.btn-row`; header print is a desktop affordance).
- Result: header ≤ ~52px tall, one row, at 390/360/320px. Desktop header unchanged.
### Fix 2 — readable charts on phones
Two parts:
- **Bump SVG base fonts in `app.js`** (affects all viewports, small improvement everywhere): `axes()` tick labels 11→13, axis-title/"age" 11→12; the `retire` annotation 10.5→12; `runs out ≈` 12→13; G3 `shortfall from ~` 12→13; G4 bar value labels 12.5→13.5 and bar name labels 10.5→12.
- **Horizontal scroll on mobile**: in `style.css` `@media(max-width:860px)`: `.chartbox{overflow-x:auto;overflow-y:hidden}` and `.chartbox svg{min-width:560px}`. Add a subtle "swipe →" hint: CSS-only via `.chartbox::after{content:"swipe →";…}` shown only ≤860px (position it top-right of the chartbox, tiny, muted). Optionally hide it after the first scroll with ≤3 lines of JS — not required.
- Keep `width:100%;height:auto` so desktop is unchanged.
### Fix 3 — bottom sheet redesign (mobile only; desktop panel untouched)
Current: fixed sheet, `transform:translateY(calc(100% - 58px))` peek, chips+insight inside, tap header toggles.
New (≤860px only):
- **Chips always visible** in the collapsed sheet (this is the user's core "building feedback" requirement — on mobile it must not hide behind a tap). `.insight{display:none}` by default on mobile; `.panel.open .insight{display:block}`.
- Remove the `transform` peek trick entirely on mobile — the sheet height is just its content (header + 2×2 chips ≈ 150px; +insight when open).
- Keep the `.panel-h` tap-to-toggle (existing document click handler) — it now toggles the insight line. Keep the ▲/▼ affordance.
- **Add a scrim**: add `<div id="scrim" hidden></div>` to `site/index.html` (inside `<main>`, after `#scr-quiz` is fine — it is `position:fixed`). CSS ≤860px: `#scrim{position:fixed;inset:0;background:#16233b33;z-index:35}` shown only when the panel has `.open` (use `#scrim.on{display:block}` — toggle a class, not `hidden`, or use `[hidden]{display:none}` carefully; simplest: JS toggles `hidden` + a class). Panel z-index stays 40 (above scrim), header 30 (below). JS: clicking `#scrim` removes `.open` from `#panel` (and hides scrim). On desktop the scrim never shows (media query display:none).
- **Safe areas + content clearance**: `.panel{bottom:calc(8px + env(safe-area-inset-bottom))}` and on mobile `.scr{padding-bottom:calc(185px + env(safe-area-inset-bottom))}` so the step content (incl. the Next button) clears the always-visible sheet.
- Verify with screenshots at 390/360/320: collapsed sheet shows header + 4 chips with values fully visible (no mid-glyph clipping); Next button reachable; tapping the sheet header reveals the insight; tapping the scrim closes it.
### Fix 4 — touch targets + tap zoom
`style.css`: `.ghost{min-height:44px}` (harmless on desktop) and `button,input{touch-action:manipulation}` (kills the 300ms double-tap zoom delay on mobile).
### Fix 5 — year-by-year table on small screens
In `style.css` `@media(max-width:560px)`: hide the two least decision-relevant columns — `th:nth-child(4),td:nth-child(4)` (Non-reg) and `th:nth-child(9),td:nth-child(9)` (GIS) — with `display:none`. **Keep** the Unfunded column (depletion rows are also highlighted red via `tr.dep`, but the explicit shortfall number is the most decision-relevant). Bump `table{font-size:13px}` on mobile. Add a right-edge fade on `.tbl-wrap` (CSS `::after` gradient overlay, `pointer-events:none`) so the horizontal scroll is discoverable. Keep the sticky first column (`td:first-child,th:first-child` sticky left) and sticky header row — verify in the after-screenshots that the Age column stays visible while scrolled right.
### Fix 6 — vision card race (app.js)
Current: `fetch('vision/manifest.json')…then(m => visionManifest = m)` at load; `renderVision` uses `visionManifest` if set, else falls back to gradient permanently for that render.
Fix: after the fetch resolves (and on fetch failure), if the results screen is currently shown and the vision card is in gradient mode, re-render the vision card with the manifest. Minimal implementation: keep a `visionReady` promise; in its `.then`/`.catch`, set `visionManifest` and call `renderVision(state.a)` again (guard with `if (state.screen === 'results')`). Keep the `img.onerror → grad()` fallback. Also add a 2.5s timeout fallback so a hung fetch can't leave the card gradient forever if images actually exist — actually simpler: on timeout just leave the gradient (images may genuinely be missing); the important fix is re-rendering once the manifest lands. Verify: load `https://retire.krisforbes.ca/#p=<shared-link>` fresh (or set localStorage screen=results and reload) → the vision card shows the real image, not the gradient. The audit script's results screenshots must show a real photo.
### Fix 7 — honest share fallback (app.js)
In `#btn-share` handler: if `navigator.clipboard.writeText` succeeds → "✅ Link copied". Otherwise (missing API or rejection) → `window.prompt('Copy your link:', url)` and set the button text to "🔗 Link shown — copy it" for 1.8s. Never claim "copied" when it wasn't.
### Fix 8 — polish
- `site/index.html`: add `<meta name="theme-color" content="#f7f9fc">` in `<head>`.
- `style.css`: `.legend{font-size:13px}`, `.opt small{font-size:12px}`.
- Optional (only if trivial): in `renderStep`, for `type:'number'` steps add a keydown listener on `#f1`: Enter → if Next is enabled, click it. Skip if it complicates the smoke shim.
## Hard constraints
- **Do not touch** `site/engine/engine.js`, `site/engine/canada.js`, any tax/benefit constants, or engine logic. This pass is UI/CSS only (plus the vision re-render and share fallback in `app.js`).
- Keep the marker `<!-- retire-planner v1 -->` as the first line inside `<head>` (deploy.sh greps for it).
- Keep the privacy statement text untouched.
- No frameworks, no build step, no CDN — vanilla files only.
- **Both test suites must stay green**: `node tests/engine.test.js` (36/36) and `node tests/ui.smoke.js` (SMOKE-OK). If a change breaks the smoke shim, extend the shim minimally — do NOT weaken assertions.
- Desktop (≥861px) layout must remain visually unchanged except the small font bumps in Fix 2/8.
## Verification steps (DSH must do all, capture evidence)
1. `node tests/engine.test.js` → 36/36 PASS; `node tests/ui.smoke.js` → SMOKE-OK.
2. Preserve before-screenshots: `mkdir -p audit/mobile-before && cp /home/ubuntu/.hermes/cache/scratch/mobile-audit/*.png audit/mobile-before/`
3. Deploy: `bash scripts/deploy.sh` → ends with `DEPLOY-OK`.
4. Origin check: `curl -s http://192.168.0.123:8103/ | grep -c 'retire-planner v1'` → 1.
5. Re-run the mobile audit: `python3 /home/ubuntu/.hermes/cache/scratch/mobile_audit.py` (needs the playwright install already on this box + `/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome`). Then `mkdir -p audit/mobile-after && cp /home/ubuntu/.hermes/cache/scratch/mobile-audit/*.png audit/mobile-after/`
6. Programmatic geometry re-check on 390px (extend the audit script or a small inline Playwright snippet): header height ≤ 56px; every `.ghost`/`.opt`/`.cta` ≥ 44px tall; chart svg `min-width` 560px on mobile (scrollWidth > clientWidth inside `.chartbox`); vision card contains an `<img>` on fresh results load; collapsed sheet shows all 4 chip values fully (no clipping — compare chip bounding boxes vs `window.innerHeight`).
7. Off-LAN spot check from the Midhurst troublebox (paramiko; password candidates live in `~/projects/*.py` lines containing `b1tvvt`, host `100.96.20.61`, try `trouble` port 2222 first): `curl -s -o /dev/null -w '%{http_code}' https://retire.krisforbes.ca/` → 200, and one vision image → 200.
8. Commit + push everything to `gitea.krisforbes.ca/krisf/retire-planner` (main): site changes + `audit/mobile-before/` + `audit/mobile-after/` + this spec as `MOBILE-FIX.md` + a short `MOBILE-FIX-REPORT.md` mapping each numbered issue → fix → verification evidence.
## Definition of done
- All 8 fixes implemented; tests green; deployed; origin + off-LAN 200; after-screenshots show: single-row header, readable charts (min-width scroll), always-visible chips sheet with scrim, 44px+ touch targets, table with hidden Non-reg/GIS columns + fade, real vision image on fresh results load.
- `MOBILE-FIX-REPORT.md` committed + pushed.
- Your FINAL output line must be exactly: `MOBILE_PASS_DONE`
+93
View File
@@ -0,0 +1,93 @@
{
"quiz": {
"hdrH": 55,
"hdrRows": 1,
"touchTargets": [
{
"id": "btn-restart",
"h": 44
},
{
"id": "btn-back",
"h": 44
},
{
"id": "btn-next",
"h": 50
}
],
"minTouch": 44,
"panelTop": 618,
"panelBottom": 836,
"innerH": 844,
"nChips": 4,
"chipVals": [
{
"label": "Savings at retirement",
"value": "$959,835",
"fullyInView": true,
"h": 29
},
{
"label": "CPP + OAS at 67",
"value": "$19,874/yr",
"fullyInView": true,
"h": 29
},
{
"label": "Money lasts to",
"value": "95+",
"fullyInView": true,
"h": 29
},
{
"label": "Lifetime tax",
"value": "$310k",
"fullyInView": true,
"h": 29
}
],
"sheetCollapsed": true,
"insightShownByDefault": false,
"nextClearsSheet": true,
"themeColor": "#f7f9fc"
},
"sheet_open": {
"open": true,
"insightShown": true,
"scrimVisible": true,
"scrimZ": 35,
"panelZ": 40,
"hdrZ": 30
},
"scrim_close": {
"panelOpen": false,
"scrimHidden": true
},
"results": {
"g1svgW": 560,
"g1svgMinW": "560px",
"g1scrollable": true,
"g1clientW": 356,
"g1scrollW": 580,
"tickFontSizeAttr": 13,
"tickRenderedPx": 11.4,
"legendPx": "13px",
"tblFont": "13px",
"col4Hidden": true,
"col9Hidden": true,
"tblScrollW": 508,
"tblClientW": 326,
"fadePresent": true,
"visionIsImg": true,
"visionGrad": false
},
"table_sticky": {
"scrolled": 182,
"ageColVisible": true,
"ageColLeftInBox": 1
},
"swipe_hint": {
"hasSwipe": true
}
}
+116
View File
@@ -0,0 +1,116 @@
"""Spec MOBILE-FIX.md step 6: programmatic geometry re-check at 390px (after fixes)."""
import json
from playwright.sync_api import sync_playwright
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,
"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"]}
with sync_playwright() as pw:
b = pw.chromium.launch(executable_path=CHROME, headless=True, args=['--no-sandbox','--disable-dev-shm-usage'])
ctx = b.new_context(viewport={'width': 390, 'height': 844}, device_scale_factor=3.0,
is_mobile=True, has_touch=True,
user_agent='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')
page = ctx.new_page()
page.goto(BASE, wait_until='networkidle')
def set_state(screen, step):
page.evaluate("""([ls,a,screen,step]) => localStorage.setItem(ls, JSON.stringify({a,step,screen}))""", [LS, ANSWERS, screen, step])
R = {}
# --- quiz screen ---
set_state('quiz', 3)
page.reload(wait_until='networkidle'); page.wait_for_timeout(700)
R['quiz'] = page.evaluate("""() => {
const vis = e => { const b=e.getBoundingClientRect(); return b.width>0 && b.height>0; };
const hdr = document.querySelector('.hdr').getBoundingClientRect();
const touches = [...document.querySelectorAll('.ghost,.opt,.cta')].filter(vis)
.map(e => ({id: e.id||e.className, h: Math.round(e.getBoundingClientRect().height)}));
const minTouch = Math.min(...touches.map(t=>t.h));
const panel = document.querySelector('.panel').getBoundingClientRect();
const chips = [...document.querySelectorAll('#panel-chips .chip')];
const chipVals = chips.map(c => {
const v = c.querySelector('.v'), b = v.getBoundingClientRect();
const r = document.createRange(); r.selectNodeContents(v);
const rb = r.getBoundingClientRect();
return {label: c.querySelector('.k').textContent, value: v.textContent,
fullyInView: b.top>=0 && b.bottom<=window.innerHeight && rb.bottom<=window.innerHeight,
h: Math.round(b.height)};
});
const sheetCollapsed = !document.querySelector('.panel').classList.contains('open');
const insightShown = getComputedStyle(document.querySelector('.insight')).display!=='none';
const next = document.querySelector('#btn-next').getBoundingClientRect();
const theme = document.querySelector('meta[name=theme-color]');
return {hdrH: Math.round(hdr.height), hdrRows: Math.round(hdr.height/40),
touchTargets: touches, minTouch, panelTop: Math.round(panel.top), panelBottom: Math.round(panel.bottom),
innerH: window.innerHeight, nChips: chips.length, chipVals, sheetCollapsed,
insightShownByDefault: insightShown,
nextClearsSheet: Math.round(next.bottom) <= Math.round(panel.top),
themeColor: theme ? theme.content : null};
}""")
# sheet expand + scrim
page.click('.panel-h'); page.wait_for_timeout(400)
R['sheet_open'] = page.evaluate("""() => {
const p = document.querySelector('.panel'), s = document.getElementById('scrim');
return {open: p.classList.contains('open'),
insightShown: getComputedStyle(document.querySelector('.insight')).display!=='none',
scrimVisible: !!s && !s.hidden && getComputedStyle(s).display!=='none',
scrimZ: +getComputedStyle(s).zIndex, panelZ: +getComputedStyle(p).zIndex,
hdrZ: +getComputedStyle(document.querySelector('.hdr')).zIndex};
}""")
R['scrim_close'] = page.evaluate("""() => {
document.getElementById('scrim').click();
const p=document.querySelector('.panel'), s=document.getElementById('scrim');
return {panelOpen: p.classList.contains('open'), scrimHidden: s.hidden};
}""")
# --- results screen ---
set_state('results', 0)
page.reload(wait_until='networkidle'); page.wait_for_timeout(1500)
R['results'] = page.evaluate("""() => {
const box = document.querySelector('#g1');
const svg = box.querySelector('svg');
const fs = svg ? +svg.querySelector('text').getAttribute('font-size') : null;
const rendered = fs ? Math.round(fs * svg.getBoundingClientRect().width / 640 * 10)/10 : null;
const tw = document.querySelector('.tbl-wrap');
const th = document.querySelector('#yr-table th:nth-child(4)');
const th9 = document.querySelector('#yr-table th:nth-child(9)');
const cs = getComputedStyle(document.querySelector('table'));
return {g1svgW: Math.round(svg.getBoundingClientRect().width),
g1svgMinW: getComputedStyle(svg).minWidth,
g1scrollable: box.scrollWidth > box.clientWidth,
g1clientW: box.clientWidth, g1scrollW: box.scrollWidth,
tickFontSizeAttr: fs, tickRenderedPx: rendered,
legendPx: getComputedStyle(document.querySelector('.legend')).fontSize,
tblFont: cs.fontSize,
col4Hidden: getComputedStyle(th).display==='none',
col9Hidden: getComputedStyle(th9).display==='none',
tblScrollW: tw.scrollWidth, tblClientW: tw.clientWidth,
fadePresent: getComputedStyle(tw,'::before').backgroundImage.includes('gradient'),
visionIsImg: !!document.querySelector('#vision-card img'),
visionGrad: document.querySelector('#vision-card').classList.contains('grad')};
}""")
# table scrolled right: Age column sticky?
page.evaluate("() => { document.querySelector('details.yearly').open = true; }")
page.wait_for_timeout(200)
page.locator('#yr-table').scroll_into_view_if_needed(); page.wait_for_timeout(300)
R['table_sticky'] = page.evaluate("""() => {
const tw = document.querySelector('.tbl-wrap'); tw.scrollLeft = 300;
const first = document.querySelector('#yr-table td:first-child').getBoundingClientRect();
const box = tw.getBoundingClientRect();
return {scrolled: tw.scrollLeft, ageColVisible: first.left >= box.left - 1 && first.right > box.left,
ageColLeftInBox: Math.round(first.left - box.left)};
}""")
# chart swipe hint visible?
R['swipe_hint'] = page.evaluate("""() => {
const b=document.querySelector('#g1'); const c=getComputedStyle(b,'::after').content + '|' + getComputedStyle(b,'::before').content;
return {hasSwipe: c.includes('swipe')};
}""")
print(json.dumps(R, indent=1))
ctx.close(); b.close()
Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 953 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

+126
View File
@@ -0,0 +1,126 @@
"""Mobile UX audit for https://retire.krisforbes.ca — screenshots at phone viewports.
Sets localStorage answers, reloads, captures key screens. Output: scratch/mobile-audit/*.png
"""
import json, os, sys
from playwright.sync_api import sync_playwright
OUT = "/home/ubuntu/projects/retire-planner/audit/mobile-audit-run"
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'
DEFAULTS = {
"province": None, "age": 45, "household": "single",
"partner": {"age": 45, "income": 0, "rrsp": 0, "tfsa": 0, "nonreg": 0, "monthlyContribution": 0},
"income": 60000, "rrsp": 100000, "tfsa": 30000, "nonreg": 20000,
"monthlyContribution": 500, "employerMatchMonthly": 0,
"retireAge": 65, "spendingAnnual": 50000,
"cppStartAge": 65, "oasStartAge": 65,
"pensionAnnual": 0, "rentalAnnual": 0, "otherAnnual": 0,
"realReturnPre": 3.5, "realReturnPost": 2.5, "inflation": 2.0, "lifeExp": 95,
"interests": []
}
ANSWERS = dict(DEFAULTS)
ANSWERS.update({
"province": "ON", "age": 45, "income": 85000, "rrsp": 250000, "tfsa": 60000,
"nonreg": 40000, "monthlyContribution": 800, "retireAge": 65, "spendingAnnual": 60000,
"pensionAnnual": 12000, "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'),
]
# (device, tag, screen, step, extra_action)
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', 'quiz-province','quiz', 0, None),
('pixel', 'results-charts','results', 0, 'charts'),
('se', 'quiz-income', 'quiz', 3, None),
('se', 'results-charts','results', 0, 'charts'),
]
def set_state(page, screen, step):
page.evaluate("""([ls, a, screen, step]) => {
const st = {a: a, step: step, screen: screen};
localStorage.setItem(ls, JSON.stringify(st));
}""", [LS, ANSWERS, screen, step])
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=True,
has_touch=True, 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]:
set_state(page, screen, step)
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:', e)
elif action == 'charts':
el = page.locator('#g1')
if el.count():
el.scroll_into_view_if_needed()
page.wait_for_timeout(600) # let G4 async render
elif action == 'table':
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))
print('saved', path)
# also measure key geometry on the widest phone for the report
if dev == 'iphone14':
set_state(page, 'quiz', 3)
page.reload(wait_until='networkidle')
page.wait_for_timeout(500)
geo = page.evaluate("""() => {
const r = s => { const e = document.querySelector(s); if (!e) return null; const b = e.getBoundingClientRect(); return {w: Math.round(b.width), h: Math.round(b.height), top: Math.round(b.top + window.scrollY), bottom: Math.round(b.bottom + window.scrollY)}; };
return {
docH: document.documentElement.scrollHeight,
innerH: window.innerHeight,
panel: r('.panel'), panelH: r('.panel-h'), chips: r('#panel-chips'),
stepBody: r('#step-body'), btnNext: r('#btn-next'), btnBack: r('#btn-back'),
hdr: r('.hdr'), g1: r('#g1'), tblWrap: r('.tbl-wrap'),
minTouch: Math.min(...[...document.querySelectorAll('.ghost,.opt,.cta')].map(e => Math.round(e.getBoundingClientRect().height)))
};
}""")
print('GEO iphone14 quiz:', json.dumps(geo))
set_state(page, 'results', 0)
page.reload(wait_until='networkidle')
page.wait_for_timeout(1200)
geo2 = page.evaluate("""() => {
const r = s => { const e = document.querySelector(s); if (!e) return null; const b = e.getBoundingClientRect(); return {w: Math.round(b.width), h: Math.round(b.height)}; };
const svg = document.querySelector('#g1 svg');
const txt = svg ? svg.querySelector('text') : null;
return {
g1: r('#g1'), g1svg: svg ? {w: Math.round(svg.getBoundingClientRect().width), h: Math.round(svg.getBoundingClientRect().height)} : null,
firstSvgTextPx: txt ? Math.round(640 / svg.getBoundingClientRect().width * 11) : null,
tblScrollW: (() => { const t = document.querySelector('.tbl-wrap'); return t ? {clientW: t.clientWidth, scrollW: t.scrollWidth} : null; })(),
visionImg: r('.vision img')
};
}""")
print('GEO iphone14 results:', json.dumps(geo2))
ctx.close()
browser.close()
print('AUDIT-DONE')
+43
View File
@@ -0,0 +1,43 @@
"""Supplementary after-screenshots: sheet collapsed, sheet open (scrim+insight),
table scrolled right (fade + sticky Age). 390px, live site."""
from playwright.sync_api import sync_playwright
CHROME = '/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome'
BASE = 'https://retire.krisforbes.ca/'
LS = 'retire-planner-v1'
A = {"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,
"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"]}
OUT = '/home/ubuntu/projects/retire-planner/audit/mobile-after'
SET_STATE = "([ls, a, step, screen]) => { localStorage.setItem(ls, JSON.stringify({ a: a, step: step, screen: screen })); }"
with sync_playwright() as pw:
b = pw.chromium.launch(executable_path=CHROME, headless=True, args=['--no-sandbox', '--disable-dev-shm-usage'])
ctx = b.new_context(viewport={'width': 390, 'height': 844}, device_scale_factor=3.0, is_mobile=True, has_touch=True,
user_agent='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')
p = ctx.new_page()
p.goto(BASE, wait_until='networkidle')
p.evaluate(SET_STATE, [LS, A, 3, 'quiz'])
p.reload(wait_until='networkidle')
p.wait_for_timeout(700)
p.screenshot(path=OUT + '/iphone14-sheet-collapsed.png')
p.click('.panel-h')
p.wait_for_timeout(400)
p.screenshot(path=OUT + '/iphone14-sheet-open.png')
p.evaluate(SET_STATE, [LS, A, 0, 'results'])
p.reload(wait_until='networkidle')
p.wait_for_timeout(1200)
p.evaluate("() => { document.querySelector('details.yearly').open = true; }")
p.locator('#yr-table').scroll_into_view_if_needed()
p.wait_for_timeout(400)
p.evaluate("() => { document.querySelector('.tbl-wrap').scrollLeft = 168; }")
p.wait_for_timeout(300)
p.screenshot(path=OUT + '/iphone14-table-scrolled-right.png')
print('SUPP-DONE')
ctx.close()
b.close()
+51 -15
View File
@@ -201,6 +201,7 @@
state.screen = name; state.screen = name;
['intro', 'quiz', 'results'].forEach(function (s) { $('#scr-' + s).hidden = s !== name; }); ['intro', 'quiz', 'results'].forEach(function (s) { $('#scr-' + s).hidden = s !== name; });
$('#btn-restart').hidden = name === 'intro'; $('#btn-restart').hidden = name === 'intro';
syncScrim();
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
@@ -263,6 +264,8 @@
if (f1) { if (f1) {
f1.oninput = function () { s.set(f1.value); save(); liveUpdate(); if (s.type === 'slider') $('#sv').textContent = s.fmt(+f1.value); }; f1.oninput = function () { s.set(f1.value); save(); liveUpdate(); if (s.type === 'slider') $('#sv').textContent = s.fmt(+f1.value); };
if (s.type === 'slider') f1.oninput(); if (s.type === 'slider') f1.oninput();
/* Fix 8: Enter on a number step = press Next (when enabled). */
if (s.type === 'number') f1.onkeydown = function (e) { if (e.key === 'Enter' && !btnNext.disabled) btnNext.click(); };
} }
stepBody.querySelectorAll('input[type=number][data-k]').forEach(function (inp) { stepBody.querySelectorAll('input[type=number][data-k]').forEach(function (inp) {
inp.oninput = function () { s.set(inp.dataset.k, inp.value); save(); liveUpdate(); }; inp.oninput = function () { s.set(inp.dataset.k, inp.value); save(); liveUpdate(); };
@@ -345,14 +348,14 @@
for (i = 0; i <= yt; i++) { for (i = 0; i <= yt; i++) {
var v = ymax * i / yt, y = Y(v); var v = ymax * i / yt, y = Y(v);
s += '<line x1="' + L + '" x2="' + (W - R) + '" y1="' + y.toFixed(1) + '" y2="' + y.toFixed(1) + '" stroke="#eef2f8"/>' + s += '<line x1="' + L + '" x2="' + (W - R) + '" y1="' + y.toFixed(1) + '" y2="' + y.toFixed(1) + '" stroke="#eef2f8"/>' +
'<text x="' + (L - 6) + '" y="' + (y + 4).toFixed(1) + '" text-anchor="end" font-size="11" fill="#8a97ad">' + (opts && opts.money === false ? v : k(v)) + '</text>'; '<text x="' + (L - 6) + '" y="' + (y + 4).toFixed(1) + '" text-anchor="end" font-size="13" fill="#8a97ad">' + (opts && opts.money === false ? v : k(v)) + '</text>';
} }
var step = a1 - a0 > 34 ? 10 : 5; var step = a1 - a0 > 34 ? 10 : 5;
for (var a = Math.ceil(a0 / step) * step; a <= a1; a += step) { for (var a = Math.ceil(a0 / step) * step; a <= a1; a += step) {
var x = X(a); var x = X(a);
s += '<text x="' + x.toFixed(1) + '" y="' + (H - B + 16) + '" text-anchor="middle" font-size="11" fill="#8a97ad">' + a + '</text>'; s += '<text x="' + x.toFixed(1) + '" y="' + (H - B + 16) + '" text-anchor="middle" font-size="13" fill="#8a97ad">' + a + '</text>';
} }
s += '<text x="' + (W / 2) + '" y="' + (H - 4) + '" text-anchor="middle" font-size="11" fill="#8a97ad">age</text>'; s += '<text x="' + (W / 2) + '" y="' + (H - 4) + '" text-anchor="middle" font-size="12" fill="#8a97ad">age</text>';
return s; return s;
} }
function svgWrap(inner) { function svgWrap(inner) {
@@ -377,11 +380,11 @@
if (proj.depletionAge) { if (proj.depletionAge) {
var x = X(proj.depletionAge); var x = X(proj.depletionAge);
inner += '<line x1="' + x + '" x2="' + x + '" y1="' + T + '" y2="' + (H - B) + '" stroke="#dc2626" stroke-dasharray="4 4" stroke-width="2"/>' + inner += '<line x1="' + x + '" x2="' + x + '" y1="' + T + '" y2="' + (H - B) + '" stroke="#dc2626" stroke-dasharray="4 4" stroke-width="2"/>' +
'<text x="' + (x + 5) + '" y="' + (T + 12) + '" font-size="12" fill="#dc2626" font-weight="700">runs out ≈ ' + proj.depletionAge + '</text>'; '<text x="' + (x + 5) + '" y="' + (T + 12) + '" font-size="13" fill="#dc2626" font-weight="700">runs out ≈ ' + proj.depletionAge + '</text>';
} }
var ret = Math.max(a0, inputs().retireAge); var ret = Math.max(a0, inputs().retireAge);
inner += '<line x1="' + X(ret) + '" x2="' + X(ret) + '" y1="' + T + '" y2="' + (H - B) + '" stroke="#94a3b8" stroke-dasharray="2 4"/>' + inner += '<line x1="' + X(ret) + '" x2="' + X(ret) + '" y1="' + T + '" y2="' + (H - B) + '" stroke="#94a3b8" stroke-dasharray="2 4"/>' +
'<text x="' + (X(ret) + 4) + '" y="' + (H - B - 6) + '" font-size="10.5" fill="#94a3b8">retire</text>'; '<text x="' + (X(ret) + 4) + '" y="' + (H - B - 6) + '" font-size="12" fill="#94a3b8">retire</text>';
$('#g1').innerHTML = svgWrap(inner) + legend([['Total savings (today’s $)', COL.bal]]); $('#g1').innerHTML = svgWrap(inner) + legend([['Total savings (today’s $)', COL.bal]]);
} }
@@ -423,7 +426,7 @@
var first = yrs.map(function (r, i) { return r.unfunded > 1 ? ages[i] : null; }).filter(function (x) { return x; })[0]; var first = yrs.map(function (r, i) { return r.unfunded > 1 ? ages[i] : null; }).filter(function (x) { return x; })[0];
if (first) { if (first) {
inner += '<circle cx="' + X(first) + '" cy="' + Y(need[ages.indexOf(first)]) + '" r="4.5" fill="#dc2626"/>' + inner += '<circle cx="' + X(first) + '" cy="' + Y(need[ages.indexOf(first)]) + '" r="4.5" fill="#dc2626"/>' +
'<text x="' + (X(first) + 6) + '" y="' + (Y(need[ages.indexOf(first)]) - 6) + '" font-size="12" fill="#dc2626" font-weight="700">shortfall from ~' + first + '</text>'; '<text x="' + (X(first) + 6) + '" y="' + (Y(need[ages.indexOf(first)]) - 6) + '" font-size="13" fill="#dc2626" font-weight="700">shortfall from ~' + first + '</text>';
} }
$('#g3').innerHTML = svgWrap(inner) + legend([['Cash you’ll have (after tax)', COL.cash], ['Planned spending', COL.need]]); $('#g3').innerHTML = svgWrap(inner) + legend([['Cash you’ll have (after tax)', COL.cash], ['Planned spending', COL.need]]);
} }
@@ -451,22 +454,32 @@
for (var i = 0; i <= 4; i++) { for (var i = 0; i <= 4; i++) {
var v = ymax * i / 4, y = sy(ymax)(v); var v = ymax * i / 4, y = sy(ymax)(v);
inner += '<line x1="' + L + '" x2="' + (W - R) + '" y1="' + y + '" y2="' + y + '" stroke="#eef2f8"/>' + inner += '<line x1="' + L + '" x2="' + (W - R) + '" y1="' + y + '" y2="' + y + '" stroke="#eef2f8"/>' +
'<text x="' + (L - 6) + '" y="' + (y + 4) + '" text-anchor="end" font-size="11" fill="#8a97ad">' + k(v) + '</text>'; '<text x="' + (L - 6) + '" y="' + (y + 4) + '" text-anchor="end" font-size="13" fill="#8a97ad">' + k(v) + '</text>';
} }
res.forEach(function (r, i) { res.forEach(function (r, i) {
var hgt = (H - T - B) * r[1] / ymax, x = L + i * bw + bw * 0.16, y = H - B - hgt; var hgt = (H - T - B) * r[1] / ymax, x = L + i * bw + bw * 0.16, y = H - B - hgt;
var col = i === 0 ? '#94a3b8' : (r[1] >= res[0][1] ? '#059669' : '#d97706'); var col = i === 0 ? '#94a3b8' : (r[1] >= res[0][1] ? '#059669' : '#d97706');
inner += '<rect x="' + x + '" y="' + y + '" width="' + bw * 0.68 + '" height="' + hgt + '" rx="6" fill="' + col + '"/>' + inner += '<rect x="' + x + '" y="' + y + '" width="' + bw * 0.68 + '" height="' + hgt + '" rx="6" fill="' + col + '"/>' +
'<text x="' + (x + bw * 0.34) + '" y="' + (y - 6) + '" text-anchor="middle" font-size="12.5" font-weight="700" fill="#16233b">' + k(r[1]) + '</text>' + '<text x="' + (x + bw * 0.34) + '" y="' + (y - 6) + '" text-anchor="middle" font-size="13.5" font-weight="700" fill="#16233b">' + k(r[1]) + '</text>' +
'<text x="' + (x + bw * 0.34) + '" y="' + (H - B + 14) + '" text-anchor="middle" font-size="10.5" fill="#8a97ad">' + r[0] + '</text>'; '<text x="' + (x + bw * 0.34) + '" y="' + (H - B + 14) + '" text-anchor="middle" font-size="12" fill="#8a97ad">' + r[0] + '</text>';
}); });
$('#g4').innerHTML = svgWrap(inner); $('#g4').innerHTML = svgWrap(inner);
} }
/* ---------- vision ---------- */ /* ---------- vision ---------- */
var visionManifest = null; var visionManifest = null;
fetch('vision/manifest.json').then(function (r) { return r.ok ? r.json() : null; }) /* Fix 6: a fresh/direct load into results can render before the manifest lands.
.then(function (m) { visionManifest = m; }).catch(function () { }); When it resolves (or fails, or times out at 2.5s — then we just keep the gradient),
re-render the vision card if results is on screen, so real images replace the fallback. */
var visionReady = (function () {
var p = fetch('vision/manifest.json').then(function (r) { return r.ok ? r.json() : null; });
var timeout = new Promise(function (res) { setTimeout(function () { res(null); }, 2500); });
return Promise.race([p, timeout]);
})();
visionReady
.then(function (m) { visionManifest = m || null; })
.catch(function () { visionManifest = visionManifest || null; })
.then(function () { if (state.screen === 'results') renderVision(state.a); });
function renderVision(a) { function renderVision(a) {
var el = $('#vision-card'); var el = $('#vision-card');
@@ -596,13 +609,36 @@
$('#btn-print').onclick = function () { window.print(); }; $('#btn-print').onclick = function () { window.print(); };
$('#btn-share').onclick = function () { $('#btn-share').onclick = function () {
var url = location.origin + location.pathname + '#p=' + encState(); var url = location.origin + location.pathname + '#p=' + encState();
var done = function () { $('#btn-share').textContent = '✅ Link copied'; setTimeout(function () { $('#btn-share').textContent = '🔗 Copy link with my answers'; }, 1800); }; var restore = function () { $('#btn-share').textContent = '🔗 Copy link with my answers'; };
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(url).then(done, done); var done = function () { $('#btn-share').textContent = '✅ Link copied'; setTimeout(restore, 1800); };
else done(); /* 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';
setTimeout(restore, 1800);
};
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(url).then(done, fail);
else fail();
}; };
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
var h = document.querySelector('.panel-h'); var h = document.querySelector('.panel-h');
if (h && h.contains(e.target)) document.getElementById('panel').classList.toggle('open'); if (h && h.contains(e.target)) {
document.getElementById('panel').classList.toggle('open');
syncScrim();
}
});
/* Fix 3: scrim behind the open sheet on mobile; tapping it closes the sheet. */
function syncScrim() {
var scrim = document.getElementById('scrim');
if (!scrim) return;
var open = document.getElementById('panel').classList.contains('open');
scrim.hidden = !open;
scrim.classList.toggle('on', open);
}
var scrimEl = document.getElementById('scrim');
if (scrimEl) scrimEl.addEventListener('click', function () {
document.getElementById('panel').classList.remove('open');
syncScrim();
}); });
var savedScreen = state.screen, savedStep = state.step; var savedScreen = state.screen, savedStep = state.step;
showScreen(savedScreen); showScreen(savedScreen);
+3
View File
@@ -4,6 +4,7 @@
<!-- retire-planner v1 --> <!-- retire-planner v1 -->
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f7f9fc">
<title>Retire Planner — Canadian retirement, modelled properly</title> <title>Retire Planner — Canadian retirement, modelled properly</title>
<meta name="description" content="A Canadian retirement calculator with real CPP/OAS/GIS/RRIF mechanics and live 2026 tax rates. 100% client-side — answers never leave your browser."> <meta name="description" content="A Canadian retirement calculator with real CPP/OAS/GIS/RRIF mechanics and live 2026 tax rates. 100% client-side — answers never leave your browser.">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
@@ -53,6 +54,8 @@
</div> </div>
</section> </section>
<div id="scrim" hidden></div>
<!-- RESULTS --> <!-- RESULTS -->
<section id="scr-results" class="scr" hidden> <section id="scr-results" class="scr" hidden>
<div id="vision-card" class="vision"></div> <div id="vision-card" class="vision"></div>
+34 -5
View File
@@ -19,7 +19,8 @@ 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} .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} .logo{font-size:17px}
.pill{font-size:12px;background:#e8f5ee;color:#0a5c36;border:1px solid #bfe6d0;border-radius:99px;padding:3px 10px;color:#0a5c36} .pill{font-size:12px;background:#e8f5ee;color:#0a5c36;border:1px solid #bfe6d0;border-radius:99px;padding:3px 10px;color:#0a5c36}
.ghost{background:none;border:1px solid var(--line);border-radius:10px;padding:7px 12px;font-size:14px;color:var(--ink2);cursor:pointer} .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)} .ghost:hover{border-color:var(--blue);color:var(--blue)}
/* screens */ /* screens */
@@ -36,13 +37,32 @@ b{color:var(--ink)}
/* quiz */ /* quiz */
.quiz-wrap{display:grid;grid-template-columns:1fr 320px;gap:20px;align-items:start} .quiz-wrap{display:grid;grid-template-columns:1fr 320px;gap:20px;align-items:start}
#scrim{display:none}
@media(max-width:860px){ @media(max-width:860px){
.quiz-wrap{grid-template-columns:1fr} .quiz-wrap{grid-template-columns:1fr}
.panel{position:fixed;left:8px;right:8px;bottom:8px;z-index:40;max-height:44vh;overflow:auto;box-shadow:0 -6px 24px #16233b22;transform:translateY(calc(100% - 58px));transition:transform .25s} /* Fix 1: single-row compact header (mobile only; desktop untouched) */
.panel.open{transform:none} .hdr{padding-top:env(safe-area-inset-top)}
.hdr-in{flex-wrap:nowrap;padding:5px 10px;gap:8px} /* 5px (not 6px) so .ghost's 44px min-height keeps the header ≤56px */
.logo{font-size:15px}
.hdr .ghost{padding:6px 9px;font-size:13px}
.hdr #btn-print{display:none}
/* Fix 2: charts keep desktop scale; scroll sideways on phones */
.chartbox{overflow-x:auto;overflow-y:hidden}
.chartbox::before{content:"swipe →";position:sticky;right:0;display:block;width:max-content;margin-bottom:-17px;padding:1px 8px;font-size:10px;color:var(--ink2);background:#ffffffcc;border:1px solid var(--line);border-radius:99px;pointer-events:none}
.chartbox svg{min-width:560px}
/* Fix 3: sheet height = its content; chips always visible; no transform peek */
.panel{position:fixed;left:8px;right:8px;bottom:calc(8px + env(safe-area-inset-bottom));z-index:40;max-height:44vh;overflow:auto;box-shadow:0 -6px 24px #16233b22}
.panel .insight{display:none}
.panel.open .insight{display:block}
.panel-h{cursor:pointer;position:sticky;top:0;background:var(--card)} .panel-h{cursor:pointer;position:sticky;top:0;background:var(--card)}
.panel-h::after{content:" ▲";font-size:11px;color:var(--ink2)} .panel-h::after{content:" ▲";font-size:11px;color:var(--ink2)}
.panel.open .panel-h::after{content:" ▼"} .panel.open .panel-h::after{content:" ▼"}
#scrim{position:fixed;inset:0;background:#16233b33;z-index:35}
#scrim.on{display:block}
.scr{padding-bottom:calc(185px + env(safe-area-inset-bottom))}
}
@media(max-width:700px){
.hdr .pill{display:none} /* privacy pill is repeated on the intro screen and the footer */
} }
.panel{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:14px} .panel{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:14px}
.panel-h{font-weight:700;font-size:14px;margin-bottom:8px} .panel-h{font-weight:700;font-size:14px;margin-bottom:8px}
@@ -67,7 +87,7 @@ input[type=number]:focus{outline:0;border-color:var(--blue)}
.opts{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;margin-top:6px} .opts{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;margin-top:6px}
.opt{border:2px solid var(--line);background:#fff;border-radius:10px;padding:11px 8px;text-align:center;font-size:15px;cursor:pointer} .opt{border:2px solid var(--line);background:#fff;border-radius:10px;padding:11px 8px;text-align:center;font-size:15px;cursor:pointer}
.opt.sel{border-color:var(--blue);background:#eef4ff;font-weight:700} .opt.sel{border-color:var(--blue);background:#eef4ff;font-weight:700}
.opt small{display:block;font-size:11.5px;color:var(--ink2);font-weight:400} .opt small{display:block;font-size:12px;color:var(--ink2);font-weight:400}
.err{color:var(--red);font-size:13px;min-height:18px} .err{color:var(--red);font-size:13px;min-height:18px}
.range-row{display:flex;align-items:baseline;gap:10px} .range-row{display:flex;align-items:baseline;gap:10px}
input[type=range]{width:100%;accent-color:var(--blue)} input[type=range]{width:100%;accent-color:var(--blue)}
@@ -86,7 +106,7 @@ input[type=range]{width:100%;accent-color:var(--blue)}
.cap{font-size:13.5px;margin:.2em 0 .6em} .cap{font-size:13.5px;margin:.2em 0 .6em}
.chartbox{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:10px;overflow:hidden} .chartbox{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:10px;overflow:hidden}
.chartbox svg{display:block;width:100%;height:auto} .chartbox svg{display:block;width:100%;height:auto}
.legend{display:flex;flex-wrap:wrap;gap:12px;font-size:12.5px;color:var(--ink2);padding:4px 6px 0} .legend{display:flex;flex-wrap:wrap;gap:12px;font-size:13px;color:var(--ink2);padding:4px 6px 0}
.legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:-1px} .legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:-1px}
/* vision */ /* vision */
@@ -114,6 +134,15 @@ tr.dep td{color:var(--red)}
.stamp{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px} .stamp{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px}
.btn-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px} .btn-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
/* Fix 5: year-by-year table on small screens (placed after the base table rules so font-size wins) */
@media(max-width:560px){
th:nth-child(4),td:nth-child(4),th:nth-child(9),td:nth-child(9){display:none} /* Non-reg, GIS */
table{font-size:13px}
/* right-edge fade so the horizontal scroll is discoverable (::before = first child, pinned to the visible top-right) */
.tbl-wrap{position:relative}
.tbl-wrap::before{content:"";float:right;width:26px;height:340px;background:linear-gradient(90deg,transparent,var(--card));pointer-events:none}
}
@media print{ @media print{
.hdr,.whatif,.btn-row,#scr-quiz{display:none!important} .hdr,.whatif,.btn-row,#scr-quiz{display:none!important}
body{background:#fff} body{background:#fff}
+2 -1
View File
@@ -33,13 +33,14 @@ global.document = {
querySelectorAll: () => [], querySelectorAll: () => [],
addEventListener() { }, addEventListener() { },
createElement: (t) => makeEl('el-' + t), createElement: (t) => makeEl('el-' + t),
getElementById: (id) => registry[id] || (registry[id] = makeEl(id)), // shim ext: app.js scrim/panel sync (Fix 3)
}; };
const store = {}; const store = {};
global.localStorage = { getItem: k => store[k] || null, setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }; 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.location = { hash: '', pathname: '/index.html', origin: 'http://x' };
global.history = { replaceState() { } }; global.history = { replaceState() { } };
Object.defineProperty(global, 'navigator', { value: {}, configurable: true }); Object.defineProperty(global, 'navigator', { value: {}, configurable: true });
global.window = { scrollTo() { }, print() { } }; global.window = { scrollTo() { }, print() { }, prompt() { } }; // prompt stub: Fix 7 share fallback
global.confirm = () => true; global.confirm = () => true;
global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) });