Round 2 mobile pass: CTA pinned above bottom sheet, swipe hint in chart header, anchor scroll-margin

- Issue 1: --sheet-h CSS var (live sheet height via syncSheetH: load/toggle/resize/
  panel-content), .scr clearance and sticky #btn-next/.step-nav above the sheet;
  income short step pixel-identical to round 1 (CTA top 485).
- Issue 2: chart() helper renders .chart-h (h3+p + .swipe span) into #g1-#g4;
  .chartbox::before overlay removed; hint is flex sibling in header, sticky left.
- Issue 3: [id]{scroll-margin-top:70px} so #whatif anchor lands below sticky hdr.
- G2 tick clip and Four-futures scroll documented as non-issues in report.
- verify2.py: 90/90 PASS live; before/after evidence in audit/mobile-{before,after}2/.

Spec: MOBILE-FIX-2.md. Report: MOBILE-FIX-2-REPORT.md.
This commit is contained in:
krisf committed 2026-10-09 06:44:08 -04:00
1 parent 53315e03e8
commit 1b194b6f92
37 files changed
+1007 -21

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
# MOBILE-FIX-2 — Report
Round 2 of the mobile pass on https://retire.krisforbes.ca, per `MOBILE-FIX-2.md`.
All three issues fixed, deployed, and verified on the live site at 320/360/390px (iPhone SE,
Pixel, iPhone 14) + 1280px desktop. Evidence: `audit/mobile-before2/` (pre-fix) and
`audit/mobile-after2/` (post-fix) — geometry logs, verification transcripts, viewport and
full-page screenshots (`full-*.png`).
**Result: 90/90 live verification assertions PASS (`VERIFY2-OK`), tests 36/36 + SMOKE-OK,
`DEPLOY-OK`, marker count 1, off-LAN (Midhurst) HTTP 200 + marker 1 (`OFFLAN-OK`).**
---
## Issue 1 — Primary CTA hidden behind the bottom sheet (FIXED)
**Root cause.** `.scr` clearance was a fixed `185px` while the sheet is 218px tall, and the
Back/Next rows (`position:static`) scrolled behind the sheet. The live sheet height also changes
when the panel is expanded, so no constant works.
**Fix (spec as written, + one necessary addition).**
- `:root{--sheet-h:218px}`; `syncSheetH()` in `app.js` writes
`document.documentElement.style.setProperty('--sheet-h', panel.offsetHeight+'px')` — shim-guarded
(`var h = panel && panel.offsetHeight; if (h)`), called on init (final statement), on `.panel-h`
toggle, on scrim toggle, on `resize` (`if (window.addEventListener)` guarded), and at the end of
`updatePanel()` (the panel's content lands ~120ms after load; without this the first paint
measured the empty 60px header).
- Mobile block: `.scr{padding-bottom:calc(var(--sheet-h) + 14px + env(safe-area-inset-bottom))}`,
`.step-nav{position:sticky;bottom:calc(var(--sheet-h) + 14px + env(safe-area-inset-bottom));
background:var(--bg);z-index:20;padding:6px 0}` (spec-verbatim, + see deviation D5), and
**`#btn-next{position:sticky;bottom:calc(var(--sheet-h) + 12px + env(safe-area-inset-bottom));z-index:21}`**
— the CTA itself carries the pin (see deviation D4: in this codebase `.step-nav` is the Back row
*above* `#step-body`, so a bottom-sticky `.step-nav` alone cannot lift the CTA that lives after
the step content).
**Measured (live, `verify2.py`; sheet 218px, `--sheet-h` live at `218px`/`274px` expanded):**
| Check | Before | After |
|---|---|---|
| step 0, @0: `#btn-next` bottom vs panel top (390) | 717 vs 618 — behind, untappable | **614 ≤ 616** ✓ tappable ✓ |
| step 0, @0 (360) | 717 vs 574 — behind | **570 ≤ 572** ✓ tappable ✓ |
| step 0, @0 (320) | 751 vs 474 — behind | **470 ≤ 472** ✓ tappable ✓ |
| step 13 (interests), @0: next bottom / tappable | 390: 606 ✓; **360: 665, ✗; 320: 693, ✗** | 390: 606 ✓; **360: 570 ✓; 320: 470 ✓** (all tappable) |
| step 0, @mid: nav bottom ≤ panelTop−2 / next pinned | 141 (static, mid-scroll unreachable) | nav 88/66/−1, next **614/570/470** ✓ |
| step 0, @max: last `.opt` (Nunavut) bottom ≤ panelTop−2 | 630/630/664 (mid-scroll unreachable) | **513/469/369** ✓, next tappable ✓ |
| `.scr` padding-bottom | 185px | **232px** = 218+14 |
| short step (income): nav top (doc) / CTA top | 97 / 485 | **97 / 485** (390 & 360 — not stuck, pixel-identical to round 1) |
| income at 320 (step taller than viewport) | CTA 485–535, behind sheet top 474 | **CTA pinned, bottom 470 ≤ 472** ✓ (the fix working) |
| desktop 1280: `.step-nav` position / `.scr` padding | static / 80px | **static / 80px** ✓ (navTop 107 unchanged) |
`sheetVar` was `""` before; now `218px` collapsed, `274px` while the panel is expanded —
clearance tracks the sheet live.
## Issue 2 — "swipe →" overlay painted over chart labels (FIXED)
**Root cause.** `.chartbox::before{content:"swipe →";position:absolute;top:…;left:…}` was an
overlay on top of the SVG — it sat over the tick labels.
**Fix (spec as written).** Removed the `::before` rule entirely; the chart cards' h3+p blocks moved
from `index.html` into `app.js` so every render writes the card via one `chart(sel, inner, legend)`
helper (title + caption + `<span class="swipe">swipe →</span>` in `.chart-h`, SVG, legend — all as
one innerHTML string, shim-safe). Base: `.chart-h{display:flex;align-items:flex-start;
justify-content:space-between;gap:10px}`, `.swipe{display:none}`; ≤860px: `.swipe` shown with the
spec chip style (`11.5px`, `var(--muted)` on `var(--chip)`, radius 99, `padding:3px 9px`,
nowrap, `flex:none`) + `.chart-h{position:sticky;left:0}` so the hint stays pinned while the SVG
scrolls sideways.
**Measured.** At 320/360/390, on all 4 charts: `.swipe` present, inside `.chart-h`,
`offsetParent ≠ null` (visible), inside card bounds, and **intersects 0 of 11–13 `<text>` rects**.
Desktop 1280: present, `display:none` (hidden) ✓.
## Issue 3 — Anchor jumps land under the sticky header (FIXED)
**Fix (spec as written).** Global `[id]{scroll-margin-top:70px}` (right after `html{scroll-behavior}`).
**Measured (live, 390 results → `#whatif`, 2000 ms):** `scrollY 2662` (>0 ✓), target top **70**
vs header bottom 55 → clears by 15px ✓. 360: `2771 / 70 / 55` ✓. 320: `2896 / 70 / 55` ✓.
(Before: 390 top 56 — passed by 1px; 360/320 top **0.0** — covered, FAIL.)
---
## Documented NON-issues (no changes made, per spec)
1. **G2 "$18k" y-tick looked clipped.** Artifact of the round-1 screenshot: the chart card is
taller than it is wide and the screenshot cropped at the card edge. Measured live before *and*
after: `svgFitsVertically: true`, `svgBottomMinusBoxBottom: −35` (SVG bottom sits 35px **inside**
the card), axis tick set renders clean (`["80","85","90","age"]` bottom row, 11 text nodes).
Nothing is clipped.
2. **"Four futures" bar cut at the card edge.** Intentional: `.chartbox{overflow-x:auto}` +
`.chartbox svg{min-width:560px}` (round-1 Fix 2) give charts horizontal scroll on phones —
desktop scale is preserved and the new in-header "swipe →" hint (Issue 2) now explains it.
## Tests / deploy / off-LAN
- `node tests/engine.test.js` → **36 pass, 0 fail**; `node tests/ui.smoke.js` → **SMOKE-OK**
(all round-2 JS is shim-safe: `chart()` writes one innerHTML string directly to `#g1–#g4`;
`syncSheetH` null/guard-checked; `window.addEventListener` guarded; `#lifeexp-lbl` assignment
guarded since the label is now created by the g4 header).
- `bash scripts/deploy.sh` → **DEPLOY-OK**; `curl … | grep -c 'retire-planner v1'` → **1**.
- Off-LAN via Midhurst troublebox (paramiko, password harvested by regex, never printed):
login OK (`trouble@100.96.20.61:2222`), `curl https://retire.krisforbes.ca/` → **200** (attempt 1),
marker count from box **1** → `OFFLAN-OK`.
## Deviations & notes (all forced by the codebase, all inside the hard constraints)
- **D1** `--muted:#4c5b74; --chip:#eef2f8` added to `:root` (the spec's `.swipe` style references
them; they existed only as literal colors in round 1 — same values, so nothing re-colored).
- **D2** `.chart-h>div{min-width:0}` wrapper: keeps the h3+p stack rendering pixel-equivalent to
the old block on desktop (flex `min-width:auto` would stretch the caption; desktop is unchanged
per the constraint).
- **D3** Chart h3/captions moved from `index.html` to `CHART_HDR` in `app.js` verbatim — required
by the spec's own design ("render the span … via app.js chart() helper"; every re-render must
keep the header). `#lifeexp-lbl` is now regenerated by the g4 header from `state.a.lifeExp`
(verified live: renders the answer value).
- **D4** `#btn-next` sticky pin added alongside the spec's `.step-nav` pin. The spec's premise
("the nav row is the last row on screen") doesn't match this codebase: `.step-nav` (Back + step
count) sits *above* `#step-body` and the CTA is the last element of the flow — `.step-nav`-only
sticky leaves the CTA behind the sheet (staging-measured: `nextBottom 729 > panelTop 618`,
untappable). The added rule is mobile-scoped, z-ordered under scrim(35)/sheet(40), and is what
makes the spec's Issue-1 assertions pass.
- **D5** `.quiz-main .step-nav{margin-bottom:-6px}` (mobile): the spec's `padding:6px 0` grows the
nav box by 12px and pushes short-step content down; netting the base 6px margin makes the
non-stuck short steps **pixel-identical to round 1** (income CTA top 485 = the spec's "~485",
nav box top 97 = true round-1 value measured from the git HEAD copy at all 3 widths).
Specificity raised (`.quiz-main .step-nav`) because the base `.step-nav` rule sits later in the
file (cascade order).
- **D6** Spec's "income nav top ~485": the round-1 **nav box** top in doc coords is 97 (measured on
the HEAD copy; 485 is the round-1 CTA-row top). Both are asserted unchanged after the fix.
- **Verification note:** `scrollTo` + rect reads must settle (`scroll-behavior:smooth`) —
`verify2.py` scrolls with `behavior:'instant'` + 350 ms settle before measuring; the pre-fix
transcript's `@mid/@max` rows (scrollY 0) are the artifact this fixed.
- Hard constraints honored: `site/engine/*.js` and constants untouched; `<!-- retire-planner v1 -->`
first line in `<head>`; privacy copy untouched; no frameworks/build/CDN; all new CSS in the
≤860px block (plus the two global rules the spec prescribes: `[id]{scroll-margin-top}` and
`.chart-h`/`.swipe` base, which are layout-neutral on desktop and measured unchanged
— desktop nav static, padding 80px, swipe hidden).
## Files changed
`site/style.css` (+27/−2) · `site/app.js` (+50/−8) · `site/index.html` (+1/−12).
Evidence/verifiers added: `audit/verify2.py`, `audit/measure2_run.py`, `audit/mobile_audit_run2.py`,
`audit/mobile-before2/` (6 shots + `geo-round2.txt` + pre-fix `verify-round2.txt`),
`audit/mobile-after2/` (6 viewport + 12 `full-*.png` shots, `geo-round2.txt`, `verify-round2.txt`
90 PASS / 0 FAIL, `audit-run.txt`).
+105
View File
@@ -0,0 +1,105 @@
# MOBILE PASS ROUND 2 — 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.**
Round 1 (commit 666b6c5) shipped 8 fixes. Hermes then re-audited the live site with vision-checked
screenshots + DOM geometry and found **3 remaining issues** (below). Fix exactly these; do not
re-litigate round-1 decisions.
## Evidence (measured by Hermes against the LIVE site, 2026-10-09)
Geometry script: `/home/ubuntu/.hermes/cache/scratch/measure2.py` (Playwright, mobile emulation,
390×844 / 360×800 / 320×700). Screenshots: `/home/ubuntu/.hermes/cache/scratch/mobile-audit2/`.
### Issue 1 — Primary CTA hidden behind the bottom sheet on tall steps (all 3 widths)
Step 0 (province grid, 13 options) at initial scroll (`scrollY=0`):
| Device | Next button (viewport y) | Sheet top | Next behind sheet? | Next tappable? | Nunavut bottom vs sheet top |
|---|---|---|---|---|---|
| 390×844 | 667–717 | 618 | **yes, fully** | **false** | 630 > 618 (behind) |
| 360×800 | 667–717 | 574 | **yes, fully** | **false** | 630 > 574 (behind) |
| 320×700 | 701–751 | 474 | **yes, fully** | **false** | 664 > 474 (behind) |
Root cause, two parts:
- `.scr{padding-bottom:185px}` (mobile) is **smaller than the sheet obstruction** (sheet 218px tall +
8px bottom offset + safe-area = 226px+). At max scroll (70px on 390w) the Next bottom lands at
viewport y=647, still 29px **below** the sheet top (618). The padding must be ≥ sheet obstruction + gap.
- On tall steps the Back/Next row sits below the fold at load — the primary CTA of step 1 of 14 is
invisible until the user scrolls.
**Fix (all rules scoped to `@media(max-width:860px)`):**
1. Maintain a CSS var with the live sheet height: on load, on `resize`, and whenever the panel
toggles `.open`, run `document.documentElement.style.setProperty('--sheet-h', panel.offsetHeight + 'px')`
(guard for the smoke-test shim: `const h = panel && panel.offsetHeight; if (h) …`). Default
`--sheet-h:218px` on `:root` so the first paint is correct.
2. `.scr{padding-bottom:calc(var(--sheet-h) + 14px + env(safe-area-inset-bottom))}` (replaces the 185px).
3. `.step-nav{position:sticky;bottom:calc(var(--sheet-h) + 14px + env(safe-area-inset-bottom));background:var(--bg);z-index:20;padding:6px 0}`
— Back/Next pin above the sheet on tall steps; on short steps (e.g. income) the nav is naturally
above the stick point so nothing changes visually. Keep it below the scrim (z 35) and sheet (z 40).
Give the stuck bar a solid background so content scrolling under it doesn't show through.
**Verify (programmatic, all 3 widths, step 0 AND step 12/interests):**
- At `scrollY=0`: `#btn-next` bottom ≤ panel top − 2, and `document.elementFromPoint(nextCenter)`
is inside `#btn-next` (tappable).
- While scrolling (scrollY = maxScroll/2): nav bottom stays ≤ panel top − 2 (pinned).
- At max scroll: last `.opt` bottom ≤ panel top − 2 (every option reachable above the sheet).
- Short step (income, step 3): nav top unchanged vs round 1 (~485), i.e. no visual shift when not stuck.
- Desktop (1280px): `.step-nav` computed `position` is `static`, `.scr` padding-bottom back to 80px.
### Issue 2 — "swipe →" pill overlaps the chart's top y-axis tick
Vision-confirmed at 320px: the pill (`.chartbox::before` overlay, top-left of the card) sits on top of
the topmost y-tick ("$1.05M"). At 390px it grazes the tick area too.
**Fix:** remove the `.chartbox::before` overlay entirely. Instead render the hint in the chart card
header: in `app.js`'s `chart()` helper, append `<span class="swipe">swipe →</span>` inside the
`.chart-h` div (which already holds the `<h3>`+`<p>`). CSS: `.chart-h{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}`
and `.swipe{display:none}` by default; ≤860px: `.swipe{display:inline-block;font-size:11.5px;color:var(--muted);background:var(--chip);border-radius:99px;padding:3px 9px;white-space:nowrap;flex:none}`.
The hint then lives in the header row and can never overlap SVG content. (The `.chart-h` flex change
is safe on desktop — the span is `display:none` there.)
**Verify:** at 320/360/390, for each of #g0–#g3: the `.swipe` rect does not intersect any `<text>`
rect of that chart's SVG; `.swipe` is visible (offsetParent ≠ null); hint present on all 4 charts.
### Issue 3 — anchor targets can hide behind the sticky header
The results page has in-page anchors (`#whatif`, `#years` details). The header is `position:sticky`,
so a native anchor jump can land the target underneath it. (Hermes' measurement was inconclusive —
re-measure with a 2s wait; regardless, the fix is one line and correct on desktop too, where the
header is also sticky.)
**Fix:** `style.css` (global, not media-scoped): `[id]{scroll-margin-top:70px}`.
**Verify:** on results at 390px: `location.hash='#whatif'`, wait **2000ms** (the page has
`scroll-behavior:smooth`), assert `window.scrollY > 0` AND
`document.querySelector('#whatif').getBoundingClientRect().top >= document.querySelector('.hdr').getBoundingClientRect().bottom - 1`.
### Verified NON-issues (document in the report, do not "fix")
- G2's bottom y-tick "$18k" looked half-clipped in a viewport screenshot — measured: the SVG fits
vertically (`svgFitsVertically:true`, svg bottom 35px above the box bottom). It was a screenshot
crop artifact.
- "Four futures" bar chart's third bar is cut at the card's right edge — that is the intentional
horizontal scroll container working as designed (swipe hint present).
## Hard constraints (same as MOBILE-FIX.md)
- Do not touch `site/engine/*.js` or any constants. UI/CSS/JS-flow only.
- Keep `<!-- retire-planner v1 -->` as first line in `<head>`; keep privacy text untouched.
- No frameworks/build/CDN. Desktop (≥861px) visually unchanged (sticky nav + bigger padding are
≤860px-only; `scroll-margin-top` is a global improvement, not a regression).
- `node tests/engine.test.js` → 36/36; `node tests/ui.smoke.js` → SMOKE-OK. Shim-safe JS only
(guard `panel.offsetHeight` etc.).
## Verification + delivery steps
1. Tests green (both suites).
2. `mkdir -p audit/mobile-before2 && cp /home/ubuntu/.hermes/cache/scratch/mobile-audit2/*.png audit/mobile-before2/`
(round-2 before evidence; also copy `audit/mobile-before2/geo-round2.txt` — write the measure2
output there: run `python3 /home/ubuntu/.hermes/cache/scratch/measure2.py > audit/mobile-before2/geo-round2.txt 2>&1`).
3. Deploy: `bash scripts/deploy.sh` → `DEPLOY-OK`; origin grep marker → 1.
4. Re-run `python3 /home/ubuntu/.hermes/cache/scratch/measure2.py` (after state) and save output +
screenshots: extend measure2 usage — copy its output to `audit/mobile-after2/geo-round2.txt` and
its PNGs to `audit/mobile-after2/`. Also re-run `python3 /home/ubuntu/.hermes/cache/scratch/mobile_audit.py`
and copy those PNGs to `audit/mobile-after2/full-*.png` (prefix them so they don't collide).
5. All Issue-1/2/3 verification assertions above must pass; record the numbers.
6. Off-LAN (Midhurst troublebox paramiko, `trouble@100.96.20.61:2222`, password candidates in
`~/projects/*.py` lines containing `b1tvvt`): `https://retire.krisforbes.ca/` → 200.
7. Commit + push to `krisf/retire-planner` main: site changes + `MOBILE-FIX-2.md` +
`MOBILE-FIX-2-REPORT.md` (issue → fix → measured evidence, incl. the non-issues section) +
`audit/mobile-before2/` + `audit/mobile-after2/`.
8. Your FINAL output line must be exactly: `MOBILE_PASS_2_DONE`
+98
View File
@@ -0,0 +1,98 @@
"""Round-2 mobile measurements: step-0 CTA vs sheet, g2 tick clip, anchor scroll, at 390/360/320."""
import json, os
from playwright.sync_api import sync_playwright
import sys
OUT = sys.argv[1] if len(sys.argv) > 1 else '/home/ubuntu/.hermes/cache/scratch/mobile-audit2'
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'),
]
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')
# --- step 0 (province grid, tallest step) at initial scroll ---
page.evaluate("""([ls, a]) => localStorage.setItem(ls, JSON.stringify({a: a, step: 0, screen: 'quiz'}))""", [LS, ANSWERS])
page.reload(wait_until='networkidle'); page.wait_for_timeout(600)
page.evaluate("window.scrollTo(0,0)")
g = page.evaluate("""() => {
const rc = e => { const b = e.getBoundingClientRect(); return {top: Math.round(b.top), bottom: Math.round(b.bottom), h: Math.round(b.height)}; };
const panel = document.querySelector('.panel');
const next = document.querySelector('#btn-next');
const opts = [...document.querySelectorAll('.opt')];
const last = opts[opts.length-1];
const nc = next.getBoundingClientRect();
const elAt = document.elementFromPoint(nc.left + nc.width/2, nc.top + nc.height/2);
return {
innerH: window.innerHeight,
docH: document.documentElement.scrollHeight,
maxScroll: document.documentElement.scrollHeight - window.innerHeight,
panelTop: Math.round(panel.getBoundingClientRect().top),
sheetH: Math.round(panel.getBoundingClientRect().height),
next: rc(next),
nextBehindSheet: Math.round(nc.bottom) > Math.round(panel.getBoundingClientRect().top),
nextTappable: !!(elAt && elAt.closest && elAt.closest('#btn-next')),
lastOptText: last.textContent.trim().slice(0, 20),
lastOpt: rc(last),
lastOptBehindSheet: Math.round(last.getBoundingClientRect().bottom) > Math.round(panel.getBoundingClientRect().top)
};
}""")
print(f'{dev} STEP0:', json.dumps(g))
page.screenshot(path=f'{OUT}/{dev}-step0-viewport.png')
# --- results: g2 bottom tick clip check + anchor scroll check ---
page.evaluate("""([ls, a]) => localStorage.setItem(ls, JSON.stringify({a: a, step: 0, screen: 'results'}))""", [LS, ANSWERS])
page.reload(wait_until='networkidle'); page.wait_for_timeout(1200)
g2 = page.evaluate("""() => {
const box = document.querySelector('#g2');
const svg = box ? box.querySelector('svg') : null;
if (!box || !svg) return {missing: true};
const bb = box.getBoundingClientRect(), sb = svg.getBoundingClientRect();
const ticks = [...svg.querySelectorAll('text')].map(t => t.textContent);
return {
boxH: Math.round(bb.height), svgH: Math.round(sb.height),
svgFitsVertically: sb.height <= bb.height + 1,
svgBottomMinusBoxBottom: Math.round(sb.bottom - bb.bottom),
bottomTick: ticks.slice(-4)
};
}""")
print(f'{dev} G2:', json.dumps(g2))
page.evaluate("document.querySelector('#g2').scrollIntoView({block:'center'})")
page.wait_for_timeout(300)
page.screenshot(path=f'{OUT}/{dev}-g2-viewport.png')
# --- anchor scroll under sticky header ---
ha = page.evaluate("""() => {
location.hash = '#whatif';
return new Promise(res => setTimeout(() => {
const t = document.querySelector('#whatif');
const hdr = document.querySelector('.hdr');
if (!t) return res({missing: true});
const tr = t.getBoundingClientRect(), hr = hdr.getBoundingClientRect();
res({targetTop: Math.round(tr.top), hdrBottom: Math.round(hr.bottom), covered: tr.top < hr.bottom});
}, 500));
}""")
print(f'{dev} HASH-ANCHOR:', json.dumps(ha))
ctx.close()
browser.close()
print('MEASURE2-DONE')
+15
View File
@@ -0,0 +1,15 @@
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-intro.png
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-quiz-province.png
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-quiz-income.png
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-quiz-slider.png
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-quiz-sheet-open.png
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-results-top.png
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-results-charts.png
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/iphone14-results-table.png
GEO iphone14 quiz: {"docH": 844, "innerH": 844, "panel": {"w": 374, "h": 218, "top": 618, "bottom": 836}, "panelH": {"w": 344, "h": 22, "top": 633, "bottom": 655}, "chips": {"w": 344, "h": 158, "top": 663, "bottom": 821}, "stepBody": {"w": 358, "h": 320, "top": 147, "bottom": 467}, "btnNext": {"w": 358, "h": 50, "top": 485, "bottom": 535}, "btnBack": {"w": 75, "h": 44, "top": 103, "bottom": 147}, "hdr": {"w": 390, "h": 55, "top": 0, "bottom": 55}, "g1": {"w": 0, "h": 0, "top": 0, "bottom": 0}, "tblWrap": {"w": 0, "h": 0, "top": 0, "bottom": 0}, "minTouch": 0}
GEO iphone14 results: {"g1": {"w": 358, "h": 409}, "g1svg": {"w": 560, "h": 236}, "firstSvgTextPx": 13, "tblScrollW": {"clientW": 326, "scrollW": 508}, "visionImg": {"w": 356, "h": 250}}
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/pixel-quiz-province.png
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/pixel-results-charts.png
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/se-quiz-income.png
saved /home/ubuntu/projects/retire-planner/audit/mobile-after2/shots/se-results-charts.png
AUDIT-DONE
Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+10
View File
@@ -0,0 +1,10 @@
iphone14 STEP0: {"innerH": 844, "docH": 961, "maxScroll": 117, "panelTop": 618, "sheetH": 218, "next": {"top": 564, "bottom": 614, "h": 50}, "nextBehindSheet": false, "nextTappable": true, "lastOptText": "Nunavut", "lastOpt": {"top": 587, "bottom": 630, "h": 43}, "lastOptBehindSheet": true}
iphone14 G2: {"boxH": 437, "svgH": 236, "svgFitsVertically": true, "svgBottomMinusBoxBottom": -35, "bottomTick": ["80", "85", "90", "age"]}
iphone14 HASH-ANCHOR: {"targetTop": 1537, "hdrBottom": 55, "covered": false}
pixel STEP0: {"innerH": 800, "docH": 961, "maxScroll": 161, "panelTop": 574, "sheetH": 218, "next": {"top": 520, "bottom": 570, "h": 50}, "nextBehindSheet": false, "nextTappable": true, "lastOptText": "Nunavut", "lastOpt": {"top": 587, "bottom": 630, "h": 43}, "lastOptBehindSheet": true}
pixel G2: {"boxH": 437, "svgH": 236, "svgFitsVertically": true, "svgBottomMinusBoxBottom": -35, "bottomTick": ["80", "85", "90", "age"]}
pixel HASH-ANCHOR: {"targetTop": 1547, "hdrBottom": 55, "covered": false}
se STEP0: {"innerH": 700, "docH": 995, "maxScroll": 295, "panelTop": 474, "sheetH": 218, "next": {"top": 420, "bottom": 470, "h": 50}, "nextBehindSheet": false, "nextTappable": true, "lastOptText": "Nunavut", "lastOpt": {"top": 621, "bottom": 664, "h": 43}, "lastOptBehindSheet": true}
se G2: {"boxH": 469, "svgH": 236, "svgFitsVertically": true, "svgBottomMinusBoxBottom": -67, "bottomTick": ["80", "85", "90", "age"]}
se HASH-ANCHOR: {"targetTop": 1562, "hdrBottom": 55, "covered": false}
MEASURE2-DONE
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

+121
View File
@@ -0,0 +1,121 @@
iphone14 step0 @scrollY=0: {"scrollY": 0, "maxScroll": 117, "panelTop": 618, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 97, "bottom": 153}, "next": {"top": 564, "bottom": 614}, "nextTappable": true, "lastOptBottom": 630}
PASS iphone14 step0: next bottom <= panelTop-2 @0 {"nextBottom": 614, "panelTop": 618}
PASS iphone14 step0: next tappable @0
iphone14 step0 @mid: {"scrollY": 59, "maxScroll": 117, "panelTop": 618, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 38, "bottom": 94}, "next": {"top": 564, "bottom": 614}, "nextTappable": true, "lastOptBottom": 571}
PASS iphone14 step0: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 94, "panelTop": 618}
PASS iphone14 step0: next still pinned mid (nextBottom <= panelTop-2) {"nextBottom": 614, "panelTop": 618}
iphone14 step0 @max: {"scrollY": 117, "maxScroll": 117, "panelTop": 618, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": -20, "bottom": 36}, "next": {"top": 550, "bottom": 600}, "nextTappable": true, "lastOptBottom": 513}
PASS iphone14 step0: last opt bottom <= panelTop-2 @max {"lastOptBottom": 513, "panelTop": 618}
PASS iphone14 step0: next tappable @max
iphone14 step13-interests @scrollY=0: {"scrollY": 0, "maxScroll": 6, "panelTop": 618, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 97, "bottom": 153}, "next": {"top": 556, "bottom": 606}, "nextTappable": true, "lastOptBottom": 519}
PASS iphone14 step13-interests: next bottom <= panelTop-2 @0 {"nextBottom": 606, "panelTop": 618}
PASS iphone14 step13-interests: next tappable @0
iphone14 step13-interests @mid: {"scrollY": 3, "maxScroll": 6, "panelTop": 618, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 94, "bottom": 150}, "next": {"top": 553, "bottom": 603}, "nextTappable": true, "lastOptBottom": 516}
PASS iphone14 step13-interests: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 150, "panelTop": 618}
PASS iphone14 step13-interests: next still pinned mid (nextBottom <= panelTop-2) {"nextBottom": 603, "panelTop": 618}
iphone14 step13-interests @max: {"scrollY": 6, "maxScroll": 6, "panelTop": 618, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 91, "bottom": 147}, "next": {"top": 550, "bottom": 600}, "nextTappable": true, "lastOptBottom": 513}
PASS iphone14 step13-interests: last opt bottom <= panelTop-2 @max {"lastOptBottom": 513, "panelTop": 618}
PASS iphone14 step13-interests: next tappable @max
iphone14 income-step3 (doc coords): {"navTop": 97, "navBottom": 153, "navPos": "sticky", "nextTop": 485, "nextBottom": 535, "panelTop": 618, "maxScroll": 0, "scrollY": 0}
PASS iphone14: income nav top unchanged (97, round-1) {"navTop": 97}
PASS iphone14: income CTA at round-1 (485) when not stuck, pinned when tall {"nextTop": 485, "nextBottom": 535, "panelTop": 618, "maxScroll": 0}
iphone14 SWIPE: [{"sel": " #g1", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1036, "bottom": 1060, "left": 301, "right": 363}}, {"sel": "#g2", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1445, "bottom": 1469, "left": 301, "right": 363}}, {"sel": "#g3", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1882, "bottom": 1906, "left": 301, "right": 363}}, {"sel": "#g4", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 13, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 2319, "bottom": 2343, "left": 301, "right": 363}}]
PASS iphone14 #g1: swipe hint present+visible
PASS iphone14 #g1: swipe does not intersect <text> {"texts": 12}
PASS iphone14 #g1: swipe inside card bounds
PASS iphone14 #g2: swipe hint present+visible
PASS iphone14 #g2: swipe does not intersect <text> {"texts": 12}
PASS iphone14 #g2: swipe inside card bounds
PASS iphone14 #g3: swipe hint present+visible
PASS iphone14 #g3: swipe does not intersect <text> {"texts": 12}
PASS iphone14 #g3: swipe inside card bounds
PASS iphone14 #g4: swipe hint present+visible
PASS iphone14 #g4: swipe does not intersect <text> {"texts": 13}
PASS iphone14 #g4: swipe inside card bounds
PASS iphone14: swipe on all 4 charts
iphone14 HASH-ANCHOR: {"scrollY": 2662, "targetTop": 70, "hdrBottom": 55}
PASS iphone14: anchor scrollY > 0 {"scrollY": 2662}
PASS iphone14: #whatif clears sticky header {"targetTop": 70, "hdrBottom": 55}
pixel step0 @scrollY=0: {"scrollY": 0, "maxScroll": 161, "panelTop": 574, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 97, "bottom": 153}, "next": {"top": 520, "bottom": 570}, "nextTappable": true, "lastOptBottom": 630}
PASS pixel step0: next bottom <= panelTop-2 @0 {"nextBottom": 570, "panelTop": 574}
PASS pixel step0: next tappable @0
pixel step0 @mid: {"scrollY": 81, "maxScroll": 161, "panelTop": 574, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 16, "bottom": 72}, "next": {"top": 520, "bottom": 570}, "nextTappable": true, "lastOptBottom": 549}
PASS pixel step0: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 72, "panelTop": 574}
PASS pixel step0: next still pinned mid (nextBottom <= panelTop-2) {"nextBottom": 570, "panelTop": 574}
pixel step0 @max: {"scrollY": 161, "maxScroll": 161, "panelTop": 574, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": -64, "bottom": -8}, "next": {"top": 506, "bottom": 556}, "nextTappable": true, "lastOptBottom": 469}
PASS pixel step0: last opt bottom <= panelTop-2 @max {"lastOptBottom": 469, "panelTop": 574}
PASS pixel step0: next tappable @max
pixel step13-interests @scrollY=0: {"scrollY": 0, "maxScroll": 109, "panelTop": 574, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 97, "bottom": 153}, "next": {"top": 520, "bottom": 570}, "nextTappable": true, "lastOptBottom": 578}
PASS pixel step13-interests: next bottom <= panelTop-2 @0 {"nextBottom": 570, "panelTop": 574}
PASS pixel step13-interests: next tappable @0
pixel step13-interests @mid: {"scrollY": 55, "maxScroll": 109, "panelTop": 574, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 42, "bottom": 98}, "next": {"top": 520, "bottom": 570}, "nextTappable": true, "lastOptBottom": 523}
PASS pixel step13-interests: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 98, "panelTop": 574}
PASS pixel step13-interests: next still pinned mid (nextBottom <= panelTop-2) {"nextBottom": 570, "panelTop": 574}
pixel step13-interests @max: {"scrollY": 109, "maxScroll": 109, "panelTop": 574, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": -12, "bottom": 44}, "next": {"top": 506, "bottom": 556}, "nextTappable": true, "lastOptBottom": 469}
PASS pixel step13-interests: last opt bottom <= panelTop-2 @max {"lastOptBottom": 469, "panelTop": 574}
PASS pixel step13-interests: next tappable @max
pixel income-step3 (doc coords): {"navTop": 97, "navBottom": 153, "navPos": "sticky", "nextTop": 485, "nextBottom": 535, "panelTop": 574, "maxScroll": 0, "scrollY": 0}
PASS pixel: income nav top unchanged (97, round-1) {"navTop": 97}
PASS pixel: income CTA at round-1 (485) when not stuck, pinned when tall {"nextTop": 485, "nextBottom": 535, "panelTop": 574, "maxScroll": 0}
pixel SWIPE: [{"sel": " #g1", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1085, "bottom": 1109, "left": 271, "right": 333}}, {"sel": "#g2", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1522, "bottom": 1545, "left": 271, "right": 333}}, {"sel": "#g3", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1959, "bottom": 1982, "left": 271, "right": 333}}, {"sel": "#g4", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 13, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 2428, "bottom": 2451, "left": 271, "right": 333}}]
PASS pixel #g1: swipe hint present+visible
PASS pixel #g1: swipe does not intersect <text> {"texts": 12}
PASS pixel #g1: swipe inside card bounds
PASS pixel #g2: swipe hint present+visible
PASS pixel #g2: swipe does not intersect <text> {"texts": 12}
PASS pixel #g2: swipe inside card bounds
PASS pixel #g3: swipe hint present+visible
PASS pixel #g3: swipe does not intersect <text> {"texts": 12}
PASS pixel #g3: swipe inside card bounds
PASS pixel #g4: swipe hint present+visible
PASS pixel #g4: swipe does not intersect <text> {"texts": 13}
PASS pixel #g4: swipe inside card bounds
PASS pixel: swipe on all 4 charts
pixel HASH-ANCHOR: {"scrollY": 2771, "targetTop": 70, "hdrBottom": 55}
PASS pixel: anchor scrollY > 0 {"scrollY": 2771}
PASS pixel: #whatif clears sticky header {"targetTop": 70, "hdrBottom": 55}
se step0 @scrollY=0: {"scrollY": 0, "maxScroll": 295, "panelTop": 474, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 97, "bottom": 153}, "next": {"top": 420, "bottom": 470}, "nextTappable": true, "lastOptBottom": 664}
PASS se step0: next bottom <= panelTop-2 @0 {"nextBottom": 470, "panelTop": 474}
PASS se step0: next tappable @0
se step0 @mid: {"scrollY": 148, "maxScroll": 295, "panelTop": 474, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": -51, "bottom": 5}, "next": {"top": 420, "bottom": 470}, "nextTappable": true, "lastOptBottom": 516}
PASS se step0: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 5, "panelTop": 474}
PASS se step0: next still pinned mid (nextBottom <= panelTop-2) {"nextBottom": 470, "panelTop": 474}
se step0 @max: {"scrollY": 295, "maxScroll": 295, "panelTop": 474, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": -198, "bottom": -142}, "next": {"top": 406, "bottom": 456}, "nextTappable": true, "lastOptBottom": 369}
PASS se step0: last opt bottom <= panelTop-2 @max {"lastOptBottom": 369, "panelTop": 474}
PASS se step0: next tappable @max
se step13-interests @scrollY=0: {"scrollY": 0, "maxScroll": 240, "panelTop": 474, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": 97, "bottom": 153}, "next": {"top": 420, "bottom": 470}, "nextTappable": true, "lastOptBottom": 609}
PASS se step13-interests: next bottom <= panelTop-2 @0 {"nextBottom": 470, "panelTop": 474}
PASS se step13-interests: next tappable @0
se step13-interests @mid: {"scrollY": 120, "maxScroll": 240, "panelTop": 474, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": -23, "bottom": 33}, "next": {"top": 420, "bottom": 470}, "nextTappable": true, "lastOptBottom": 489}
PASS se step13-interests: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 33, "panelTop": 474}
PASS se step13-interests: next still pinned mid (nextBottom <= panelTop-2) {"nextBottom": 470, "panelTop": 474}
se step13-interests @max: {"scrollY": 240, "maxScroll": 240, "panelTop": 474, "sheetH": 218, "sheetVar": "218px", "scrPadBottom": "232px", "navPos": "sticky", "nav": {"top": -143, "bottom": -87}, "next": {"top": 406, "bottom": 456}, "nextTappable": true, "lastOptBottom": 369}
PASS se step13-interests: last opt bottom <= panelTop-2 @max {"lastOptBottom": 369, "panelTop": 474}
PASS se step13-interests: next tappable @max
se income-step3 (doc coords): {"navTop": 97, "navBottom": 153, "navPos": "sticky", "nextTop": 420, "nextBottom": 470, "panelTop": 474, "maxScroll": 79, "scrollY": 0}
PASS se: income nav top unchanged (97, round-1) {"navTop": 97}
PASS se: income CTA at round-1 (485) when not stuck, pinned when tall {"nextTop": 420, "nextBottom": 470, "panelTop": 474, "maxScroll": 79}
se SWIPE: [{"sel": " #g1", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1108, "bottom": 1132, "left": 231, "right": 293}}, {"sel": "#g2", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 1566, "bottom": 1590, "left": 231, "right": 293}}, {"sel": "#g3", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 12, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 2035, "bottom": 2059, "left": 231, "right": 293}}, {"sel": "#g4", "hasSwipe": true, "inHeader": true, "swipeVisible": true, "swipeText": "swipe \u2192", "texts": 13, "svg": true, "intersectsText": false, "swipeInsideCard": true, "swipe": {"top": 2504, "bottom": 2528, "left": 231, "right": 293}}]
PASS se #g1: swipe hint present+visible
PASS se #g1: swipe does not intersect <text> {"texts": 12}
PASS se #g1: swipe inside card bounds
PASS se #g2: swipe hint present+visible
PASS se #g2: swipe does not intersect <text> {"texts": 12}
PASS se #g2: swipe inside card bounds
PASS se #g3: swipe hint present+visible
PASS se #g3: swipe does not intersect <text> {"texts": 12}
PASS se #g3: swipe inside card bounds
PASS se #g4: swipe hint present+visible
PASS se #g4: swipe does not intersect <text> {"texts": 13}
PASS se #g4: swipe inside card bounds
PASS se: swipe on all 4 charts
se HASH-ANCHOR: {"scrollY": 2896, "targetTop": 70, "hdrBottom": 55}
PASS se: anchor scrollY > 0 {"scrollY": 2896}
PASS se: #whatif clears sticky header {"targetTop": 70, "hdrBottom": 55}
desk1280 DESKTOP-QUIZ: {"navPos": "static", "scrPadBottom": "80px", "navTop": 107}
PASS desk1280: .step-nav position static {"navPos": "static"}
PASS desk1280: .scr padding-bottom 80px {"pad": "80px"}
desk1280 DESKTOP-SWIPE: [{"sel": "#g1", "present": true, "visible": false}, {"sel": "#g2", "present": true, "visible": false}, {"sel": "#g3", "present": true, "visible": false}, {"sel": "#g4", "present": true, "visible": false}]
PASS desk1280: desktop swipe hidden [{"sel": "#g1", "present": true, "visible": false}, {"sel": "#g2", "present": true, "visible": false}, {"sel": "#g3", "present": true, "visible": false}, {"sel": "#g4", "present": true, "visible": false}]
VERIFY2-OK
+10
View File
@@ -0,0 +1,10 @@
iphone14 STEP0: {"innerH": 844, "docH": 914, "maxScroll": 70, "panelTop": 618, "sheetH": 218, "next": {"top": 667, "bottom": 717, "h": 50}, "nextBehindSheet": true, "nextTappable": false, "lastOptText": "Nunavut", "lastOpt": {"top": 587, "bottom": 630, "h": 43}, "lastOptBehindSheet": true}
iphone14 G2: {"boxH": 285, "svgH": 236, "svgFitsVertically": true, "svgBottomMinusBoxBottom": -35, "bottomTick": ["80", "85", "90", "age"]}
iphone14 HASH-ANCHOR: {"targetTop": 1382, "hdrBottom": 55, "covered": false}
pixel STEP0: {"innerH": 800, "docH": 914, "maxScroll": 114, "panelTop": 574, "sheetH": 218, "next": {"top": 667, "bottom": 717, "h": 50}, "nextBehindSheet": true, "nextTappable": false, "lastOptText": "Nunavut", "lastOpt": {"top": 587, "bottom": 630, "h": 43}, "lastOptBehindSheet": true}
pixel G2: {"boxH": 285, "svgH": 236, "svgFitsVertically": true, "svgBottomMinusBoxBottom": -35, "bottomTick": ["80", "85", "90", "age"]}
pixel HASH-ANCHOR: {"targetTop": 1393, "hdrBottom": 55, "covered": false}
se STEP0: {"innerH": 700, "docH": 948, "maxScroll": 248, "panelTop": 474, "sheetH": 218, "next": {"top": 701, "bottom": 751, "h": 50}, "nextBehindSheet": true, "nextTappable": false, "lastOptText": "Nunavut", "lastOpt": {"top": 621, "bottom": 664, "h": 43}, "lastOptBehindSheet": true}
se G2: {"boxH": 317, "svgH": 236, "svgFitsVertically": true, "svgBottomMinusBoxBottom": -67, "bottomTick": ["80", "85", "90", "age"]}
se HASH-ANCHOR: {"targetTop": 1386, "hdrBottom": 55, "covered": false}
MEASURE2-DONE
Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+103
View File
@@ -0,0 +1,103 @@
iphone14 step0 @scrollY=0: {"scrollY": 0, "maxScroll": 70, "panelTop": 618, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 667, "bottom": 717}, "nextTappable": false, "lastOptBottom": 630}
FAIL iphone14 step0: next bottom <= panelTop-2 @0 {"nextBottom": 717, "panelTop": 618}
FAIL iphone14 step0: next tappable @0
iphone14 step0 @mid: {"scrollY": 0, "maxScroll": 70, "panelTop": 618, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 667, "bottom": 717}, "nextTappable": false, "lastOptBottom": 630}
PASS iphone14 step0: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 141, "panelTop": 618}
iphone14 step0 @max: {"scrollY": 0, "maxScroll": 70, "panelTop": 618, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 667, "bottom": 717}, "nextTappable": false, "lastOptBottom": 630}
FAIL iphone14 step0: last opt bottom <= panelTop-2 @max {"lastOptBottom": 630, "panelTop": 618}
iphone14 step13-interests @scrollY=0: {"scrollY": 0, "maxScroll": 0, "panelTop": 618, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 556, "bottom": 606}, "nextTappable": true, "lastOptBottom": 519}
PASS iphone14 step13-interests: next bottom <= panelTop-2 @0 {"nextBottom": 606, "panelTop": 618}
PASS iphone14 step13-interests: next tappable @0
iphone14 step13-interests @mid: {"scrollY": 0, "maxScroll": 0, "panelTop": 618, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 556, "bottom": 606}, "nextTappable": true, "lastOptBottom": 519}
PASS iphone14 step13-interests: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 141, "panelTop": 618}
iphone14 step13-interests @max: {"scrollY": 0, "maxScroll": 0, "panelTop": 618, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 556, "bottom": 606}, "nextTappable": true, "lastOptBottom": 519}
PASS iphone14 step13-interests: last opt bottom <= panelTop-2 @max {"lastOptBottom": 519, "panelTop": 618}
iphone14 income-step3 (doc coords): {"navTop": 97, "navBottom": 141, "navPos": "static", "scrollY": 0}
iphone14 SWIPE: [{"sel": " #g1", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g2", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g3", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g4", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 13, "svg": true}]
FAIL iphone14 #g1: swipe hint present+visible
FAIL iphone14 #g1: swipe does not intersect <text> {"texts": 12}
FAIL iphone14 #g1: swipe inside card bounds
FAIL iphone14 #g2: swipe hint present+visible
FAIL iphone14 #g2: swipe does not intersect <text> {"texts": 12}
FAIL iphone14 #g2: swipe inside card bounds
FAIL iphone14 #g3: swipe hint present+visible
FAIL iphone14 #g3: swipe does not intersect <text> {"texts": 12}
FAIL iphone14 #g3: swipe inside card bounds
FAIL iphone14 #g4: swipe hint present+visible
FAIL iphone14 #g4: swipe does not intersect <text> {"texts": 13}
FAIL iphone14 #g4: swipe inside card bounds
FAIL iphone14: swipe on all 4 charts
iphone14 HASH-ANCHOR: {"scrollY": 2562, "targetTop": 56, "hdrBottom": 55}
PASS iphone14: anchor scrollY > 0 {"scrollY": 2562}
PASS iphone14: #whatif clears sticky header {"targetTop": 56, "hdrBottom": 55}
pixel step0 @scrollY=0: {"scrollY": 0, "maxScroll": 114, "panelTop": 574, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 667, "bottom": 717}, "nextTappable": false, "lastOptBottom": 630}
FAIL pixel step0: next bottom <= panelTop-2 @0 {"nextBottom": 717, "panelTop": 574}
FAIL pixel step0: next tappable @0
pixel step0 @mid: {"scrollY": 0, "maxScroll": 114, "panelTop": 574, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 667, "bottom": 717}, "nextTappable": false, "lastOptBottom": 630}
PASS pixel step0: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 141, "panelTop": 574}
pixel step0 @max: {"scrollY": 0, "maxScroll": 114, "panelTop": 574, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 667, "bottom": 717}, "nextTappable": false, "lastOptBottom": 630}
FAIL pixel step0: last opt bottom <= panelTop-2 @max {"lastOptBottom": 630, "panelTop": 574}
pixel step13-interests @scrollY=0: {"scrollY": 0, "maxScroll": 62, "panelTop": 574, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 615, "bottom": 665}, "nextTappable": false, "lastOptBottom": 578}
FAIL pixel step13-interests: next bottom <= panelTop-2 @0 {"nextBottom": 665, "panelTop": 574}
FAIL pixel step13-interests: next tappable @0
pixel step13-interests @mid: {"scrollY": 0, "maxScroll": 62, "panelTop": 574, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 615, "bottom": 665}, "nextTappable": false, "lastOptBottom": 578}
PASS pixel step13-interests: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 141, "panelTop": 574}
pixel step13-interests @max: {"scrollY": 0, "maxScroll": 62, "panelTop": 574, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 615, "bottom": 665}, "nextTappable": false, "lastOptBottom": 578}
FAIL pixel step13-interests: last opt bottom <= panelTop-2 @max {"lastOptBottom": 578, "panelTop": 574}
pixel income-step3 (doc coords): {"navTop": 97, "navBottom": 141, "navPos": "static", "scrollY": 0}
pixel SWIPE: [{"sel": " #g1", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g2", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g3", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g4", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 13, "svg": true}]
FAIL pixel #g1: swipe hint present+visible
FAIL pixel #g1: swipe does not intersect <text> {"texts": 12}
FAIL pixel #g1: swipe inside card bounds
FAIL pixel #g2: swipe hint present+visible
FAIL pixel #g2: swipe does not intersect <text> {"texts": 12}
FAIL pixel #g2: swipe inside card bounds
FAIL pixel #g3: swipe hint present+visible
FAIL pixel #g3: swipe does not intersect <text> {"texts": 12}
FAIL pixel #g3: swipe inside card bounds
FAIL pixel #g4: swipe hint present+visible
FAIL pixel #g4: swipe does not intersect <text> {"texts": 13}
FAIL pixel #g4: swipe inside card bounds
FAIL pixel: swipe on all 4 charts
pixel HASH-ANCHOR: {"scrollY": 2699, "targetTop": -0.0, "hdrBottom": 55}
PASS pixel: anchor scrollY > 0 {"scrollY": 2699}
FAIL pixel: #whatif clears sticky header {"targetTop": -0.0, "hdrBottom": 55}
se step0 @scrollY=0: {"scrollY": 0, "maxScroll": 248, "panelTop": 474, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 701, "bottom": 751}, "nextTappable": false, "lastOptBottom": 664}
FAIL se step0: next bottom <= panelTop-2 @0 {"nextBottom": 751, "panelTop": 474}
FAIL se step0: next tappable @0
se step0 @mid: {"scrollY": 0, "maxScroll": 248, "panelTop": 474, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 701, "bottom": 751}, "nextTappable": false, "lastOptBottom": 664}
PASS se step0: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 141, "panelTop": 474}
se step0 @max: {"scrollY": 0, "maxScroll": 248, "panelTop": 474, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 97, "bottom": 141}, "next": {"top": 701, "bottom": 751}, "nextTappable": false, "lastOptBottom": 664}
FAIL se step0: last opt bottom <= panelTop-2 @max {"lastOptBottom": 664, "panelTop": 474}
se step13-interests @scrollY=0: {"scrollY": 3, "maxScroll": 193, "panelTop": 474, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 94, "bottom": 138}, "next": {"top": 643, "bottom": 693}, "nextTappable": false, "lastOptBottom": 606}
FAIL se step13-interests: next bottom <= panelTop-2 @0 {"nextBottom": 693, "panelTop": 474}
FAIL se step13-interests: next tappable @0
se step13-interests @mid: {"scrollY": 3, "maxScroll": 193, "panelTop": 474, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 94, "bottom": 138}, "next": {"top": 643, "bottom": 693}, "nextTappable": false, "lastOptBottom": 606}
PASS se step13-interests: nav pinned mid (navBottom <= panelTop-2) {"navBottom": 138, "panelTop": 474}
se step13-interests @max: {"scrollY": 3, "maxScroll": 193, "panelTop": 474, "sheetH": 218, "sheetVar": "", "scrPadBottom": "185px", "navPos": "static", "nav": {"top": 94, "bottom": 138}, "next": {"top": 643, "bottom": 693}, "nextTappable": false, "lastOptBottom": 606}
FAIL se step13-interests: last opt bottom <= panelTop-2 @max {"lastOptBottom": 606, "panelTop": 474}
se income-step3 (doc coords): {"navTop": 97, "navBottom": 141, "navPos": "static", "scrollY": 9}
se SWIPE: [{"sel": " #g1", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g2", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g3", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 12, "svg": true}, {"sel": "#g4", "hasSwipe": false, "inHeader": false, "swipeVisible": false, "swipeText": null, "texts": 13, "svg": true}]
FAIL se #g1: swipe hint present+visible
FAIL se #g1: swipe does not intersect <text> {"texts": 12}
FAIL se #g1: swipe inside card bounds
FAIL se #g2: swipe hint present+visible
FAIL se #g2: swipe does not intersect <text> {"texts": 12}
FAIL se #g2: swipe inside card bounds
FAIL se #g3: swipe hint present+visible
FAIL se #g3: swipe does not intersect <text> {"texts": 12}
FAIL se #g3: swipe inside card bounds
FAIL se #g4: swipe hint present+visible
FAIL se #g4: swipe does not intersect <text> {"texts": 13}
FAIL se #g4: swipe inside card bounds
FAIL se: swipe on all 4 charts
se HASH-ANCHOR: {"scrollY": 2810, "targetTop": -0.0, "hdrBottom": 55}
PASS se: anchor scrollY > 0 {"scrollY": 2810}
FAIL se: #whatif clears sticky header {"targetTop": -0.0, "hdrBottom": 55}
desk1280 DESKTOP-QUIZ: {"navPos": "static", "scrPadBottom": "80px", "navTop": 107}
PASS desk1280: .step-nav position static {"navPos": "static"}
PASS desk1280: .scr padding-bottom 80px {"pad": "80px"}
desk1280 DESKTOP-SWIPE: [{"sel": "#g1", "present": false, "visible": false}, {"sel": "#g2", "present": false, "visible": false}, {"sel": "#g3", "present": false, "visible": false}, {"sel": "#g4", "present": false, "visible": false}]
PASS desk1280: desktop swipe hidden [{"sel": "#g1", "present": false, "visible": false}, {"sel": "#g2", "present": false, "visible": false}, {"sel": "#g3", "present": false, "visible": false}, {"sel": "#g4", "present": false, "visible": false}]
VERIFY2-FAIL (56): iphone14 step0: next bottom <= panelTop-2 @0; iphone14 step0: next tappable @0; iphone14 step0: last opt bottom <= panelTop-2 @max; iphone14 #g1: swipe hint present+visible; iphone14 #g1: swipe does not intersect <text>; iphone14 #g1: swipe inside card bounds; iphone14 #g2: swipe hint present+visible; iphone14 #g2: swipe does not intersect <text>; iphone14 #g2: swipe inside card bounds; iphone14 #g3: swipe hint present+visible; iphone14 #g3: swipe does not intersect <text>; iphone14 #g3: swipe inside card bounds; iphone14 #g4: swipe hint present+visible; iphone14 #g4: swipe does not intersect <text>; iphone14 #g4: swipe inside card bounds; iphone14: swipe on all 4 charts; pixel step0: next bottom <= panelTop-2 @0; pixel step0: next tappable @0; pixel step0: last opt bottom <= panelTop-2 @max; pixel step13-interests: next bottom <= panelTop-2 @0; pixel step13-interests: next tappable @0; pixel step13-interests: last opt bottom <= panelTop-2 @max; pixel #g1: swipe hint present+visible; pixel #g1: swipe does not intersect <text>; pixel #g1: swipe inside card bounds; pixel #g2: swipe hint present+visible; pixel #g2: swipe does not intersect <text>; pixel #g2: swipe inside card bounds; pixel #g3: swipe hint present+visible; pixel #g3: swipe does not intersect <text>; pixel #g3: swipe inside card bounds; pixel #g4: swipe hint present+visible; pixel #g4: swipe does not intersect <text>; pixel #g4: swipe inside card bounds; pixel: swipe on all 4 charts; pixel: #whatif clears sticky header; se step0: next bottom <= panelTop-2 @0; se step0: next tappable @0; se step0: last opt bottom <= panelTop-2 @max; se step13-interests: next bottom <= panelTop-2 @0; se step13-interests: next tappable @0; se step13-interests: last opt bottom <= panelTop-2 @max; se #g1: swipe hint present+visible; se #g1: swipe does not intersect <text>; se #g1: swipe inside card bounds; se #g2: swipe hint present+visible; se #g2: swipe does not intersect <text>; se #g2: swipe inside card bounds; se #g3: swipe hint present+visible; se #g3: swipe does not intersect <text>; se #g3: swipe inside card bounds; se #g4: swipe hint present+visible; se #g4: swipe does not intersect <text>; se #g4: swipe inside card bounds; se: swipe on all 4 charts; se: #whatif clears sticky header
+127
View File
@@ -0,0 +1,127 @@
"""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
import sys
OUT = sys.argv[1] if len(sys.argv) > 1 else '/home/ubuntu/.hermes/cache/scratch/mobile-audit'
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')
+207
View File
@@ -0,0 +1,207 @@
"""Round-2 verification: MOBILE-FIX-2.md Issue 1/2/3 assertions, measured on the LIVE site.
Runs step-0 + tall-step checks (Issue 1), swipe-vs-ticks (Issue 2), anchor scroll (Issue 3),
short-step nav-top baseline, and a desktop 1280 spot-check. Prints VERIFY2-OK / VERIFY2-FAIL.
Usage: python3 audit/verify2.py > audit/mobile-<before|after>2/verify-round2.txt 2>&1
"""
import json, os, sys
from playwright.sync_api import sync_playwright
CHROME = '/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome'
BASE = os.environ.get('RP_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'),
('desk1280', 1280, 900, 1.0, 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'),
]
fails = []
def chk(name, cond, extra=None):
print((' PASS ' if cond else ' FAIL ') + name + (' ' + json.dumps(extra) if extra is not None else ''))
if not cond:
fails.append(name)
def set_state(page, screen, step):
page.evaluate("([ls, a, screen, step]) => localStorage.setItem(ls, JSON.stringify({a: a, step: step, screen: screen}))",
[LS, ANSWERS, screen, step])
def scroll_to(page, frac):
page.evaluate("""(frac) => {
const max = document.documentElement.scrollHeight - window.innerHeight;
window.scrollTo({top: Math.round(max * frac), behavior: 'instant'});
}""", frac)
page.wait_for_timeout(350) # scroll-behavior:smooth — let it settle before measuring
MEASURE_JS = """() => {
const rc = e => { const b = e.getBoundingClientRect(); return {top: Math.round(b.top), bottom: Math.round(b.bottom)}; };
const panel = document.querySelector('.panel');
const nav = document.querySelector('.step-nav');
const next = document.querySelector('#btn-next');
const opts = [...document.querySelectorAll('.opt')];
const last = opts[opts.length - 1];
const pTop = Math.round(panel.getBoundingClientRect().top);
const nc = next.getBoundingClientRect();
const elAt = document.elementFromPoint(nc.left + nc.width / 2, nc.top + nc.height / 2);
return {
scrollY: Math.round(window.scrollY),
maxScroll: Math.round(document.documentElement.scrollHeight - window.innerHeight),
panelTop: pTop,
sheetH: Math.round(panel.getBoundingClientRect().height),
sheetVar: getComputedStyle(document.documentElement).getPropertyValue('--sheet-h').trim(),
scrPadBottom: getComputedStyle(document.querySelector('#scr-quiz')).paddingBottom,
navPos: getComputedStyle(nav).position,
nav: rc(nav), next: rc(next),
nextTappable: !!(elAt && elAt.closest && elAt.closest('#btn-next')),
lastOptBottom: last ? Math.round(last.getBoundingClientRect().bottom) : null
};
}"""
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:
mobile = dev != 'desk1280'
ctx = browser.new_context(viewport={'width': w, 'height': h}, device_scale_factor=dsf,
is_mobile=mobile, has_touch=mobile, user_agent=ua)
page = ctx.new_page()
page.goto(BASE, wait_until='networkidle')
# ---------------- Issue 1: quiz, tall steps (step 0 = province grid, step 13 = interests) ----------
for step, tag in ((0, 'step0'), (13, 'step13-interests')):
if not mobile:
break # Issue 1 assertions are mobile; desktop checked separately below
set_state(page, 'quiz', step)
page.reload(wait_until='networkidle'); page.wait_for_timeout(700)
scroll_to(page, 0.0)
g0 = page.evaluate(MEASURE_JS)
print(f'{dev} {tag} @scrollY=0:', json.dumps(g0))
chk(f'{dev} {tag}: next bottom <= panelTop-2 @0', g0['next']['bottom'] <= g0['panelTop'] - 2,
{'nextBottom': g0['next']['bottom'], 'panelTop': g0['panelTop']})
chk(f'{dev} {tag}: next tappable @0', g0['nextTappable'])
scroll_to(page, 0.5)
gm = page.evaluate(MEASURE_JS)
print(f'{dev} {tag} @mid:', json.dumps(gm))
chk(f'{dev} {tag}: nav pinned mid (navBottom <= panelTop-2)',
gm['nav']['bottom'] <= gm['panelTop'] - 2, {'navBottom': gm['nav']['bottom'], 'panelTop': gm['panelTop']})
chk(f'{dev} {tag}: next still pinned mid (nextBottom <= panelTop-2)',
gm['next']['bottom'] <= gm['panelTop'] - 2, {'nextBottom': gm['next']['bottom'], 'panelTop': gm['panelTop']})
scroll_to(page, 1.0)
gmax = page.evaluate(MEASURE_JS)
print(f'{dev} {tag} @max:', json.dumps(gmax))
chk(f'{dev} {tag}: last opt bottom <= panelTop-2 @max',
gmax['lastOptBottom'] <= gmax['panelTop'] - 2, {'lastOptBottom': gmax['lastOptBottom'], 'panelTop': gmax['panelTop']})
chk(f'{dev} {tag}: next tappable @max', gmax['nextTappable'])
# ---------------- Issue 1 (short step): income (quiz step 3 single) — no shift when not stuck --------
# Round-1 (pre-fix, audit/mobile-before2/verify-round2.txt): nav doc-top = 97,
# #btn-next top = 460 / center = 485 on all 3 widths. Round 2 must reproduce that exactly.
if mobile:
set_state(page, 'quiz', 3)
page.reload(wait_until='networkidle'); page.wait_for_timeout(600)
gi = page.evaluate("""() => {
const nav = document.querySelector('.step-nav');
const nx = document.querySelector('#btn-next');
const b = nav.getBoundingClientRect(), n = nx.getBoundingClientRect();
return {navTop: Math.round(b.top + window.scrollY), navBottom: Math.round(b.bottom + window.scrollY),
navPos: getComputedStyle(nav).position,
nextTop: Math.round(n.top + window.scrollY), nextBottom: Math.round(n.bottom),
panelTop: Math.round(document.querySelector('.panel').getBoundingClientRect().top),
maxScroll: Math.round(document.documentElement.scrollHeight - window.innerHeight),
scrollY: Math.round(window.scrollY)};
}""")
print(f'{dev} income-step3 (doc coords):', json.dumps(gi))
chk(f'{dev}: income nav top unchanged (97, round-1)', gi['navTop'] == 97, {'navTop': gi['navTop']})
# Spec: "nav top ~485 unchanged … visually nothing changes on short steps". True round-1
# CTA-row top (measured on git HEAD copy at 390/360/320) = 485. Not-stuck rendering must
# reproduce it exactly. At 320 the income step is TALL (docH > vh), so round-2 pins the
# CTA above the sheet (that IS the issue-1 fix) — accept the pinned position there.
unstuck = gi['nextTop'] == 485
pinned = gi['panelTop'] - 16 <= gi['nextBottom'] <= gi['panelTop'] - 2
chk(f'{dev}: income CTA at round-1 (485) when not stuck, pinned when tall',
unstuck or pinned, {'nextTop': gi['nextTop'], 'nextBottom': gi['nextBottom'],
'panelTop': gi['panelTop'], 'maxScroll': gi['maxScroll']})
# ---------------- Issue 2: swipe hint in chart header, no text intersection ------------------------
if mobile:
set_state(page, 'results', 0)
page.reload(wait_until='networkidle'); page.wait_for_timeout(1500)
sw = page.evaluate("""() => [' #g1','#g2','#g3','#g4'].map(sel => {
const box = document.querySelector(sel.trim());
if (!box) return {sel, missing: true};
const span = box.querySelector('.swipe');
const hdr = box.querySelector('.chart-h');
const svg = box.querySelector('svg');
const texts = svg ? [...svg.querySelectorAll('text')] : [];
const out = {sel, hasSwipe: !!span, inHeader: !!(span && hdr && hdr.contains(span)),
swipeVisible: !!(span && span.offsetParent),
swipeText: span ? span.textContent.trim() : null,
texts: texts.length, svg: !!svg};
if (span && texts.length) {
const r = span.getBoundingClientRect();
out.intersectsText = texts.some(t => { const b = t.getBoundingClientRect();
return !(r.right <= b.left || r.left >= b.right || r.bottom <= b.top || r.top >= b.bottom); });
const br = box.getBoundingClientRect();
out.swipeInsideCard = r.left >= br.left - 1 && r.right <= br.right + 1 && r.top >= br.top - 1 && r.bottom <= br.bottom + 1;
out.swipe = {top: Math.round(r.top), bottom: Math.round(r.bottom), left: Math.round(r.left), right: Math.round(r.right)};
}
return out;
})""")
print(f'{dev} SWIPE:', json.dumps(sw))
for o in sw:
chk(f'{dev} {o["sel"]}: swipe hint present+visible', o.get('hasSwipe') and o.get('swipeVisible') and o.get('inHeader'))
chk(f'{dev} {o["sel"]}: swipe does not intersect <text>', o.get('intersectsText') is False,
{'texts': o.get('texts')})
chk(f'{dev} {o["sel"]}: swipe inside card bounds', o.get('swipeInsideCard') is True)
chk(f'{dev}: swipe on all 4 charts', all(o.get('hasSwipe') for o in sw))
# ---------------- Issue 3: anchor under sticky header ------------------------------------------
ha = page.evaluate("""() => {
location.hash = '#whatif';
return new Promise(res => setTimeout(() => {
const t = document.querySelector('#whatif'), hdr = document.querySelector('.hdr');
if (!t) return res({missing: true});
const tr = t.getBoundingClientRect(), hr = hdr.getBoundingClientRect();
res({scrollY: Math.round(window.scrollY), targetTop: Math.round(tr.top), hdrBottom: Math.round(hr.bottom)});
}, 2000));
}""")
print(f'{dev} HASH-ANCHOR:', json.dumps(ha))
chk(f'{dev}: anchor scrollY > 0', ha.get('scrollY', 0) > 0, {'scrollY': ha.get('scrollY')})
chk(f'{dev}: #whatif clears sticky header', ha.get('targetTop', -99) >= ha.get('hdrBottom', 999) - 1,
{'targetTop': ha.get('targetTop'), 'hdrBottom': ha.get('hdrBottom')})
else:
# ---------------- Desktop spot-check (1280): static nav, 80px scr padding, hidden hint ----------
set_state(page, 'quiz', 3)
page.reload(wait_until='networkidle'); page.wait_for_timeout(600)
d = page.evaluate("""() => ({
navPos: getComputedStyle(document.querySelector('.step-nav')).position,
scrPadBottom: getComputedStyle(document.querySelector('#scr-quiz')).paddingBottom,
navTop: Math.round(document.querySelector('.step-nav').getBoundingClientRect().top + window.scrollY)
})""")
print(f'{dev} DESKTOP-QUIZ:', json.dumps(d))
chk(f'{dev}: .step-nav position static', d['navPos'] == 'static', {'navPos': d['navPos']})
chk(f'{dev}: .scr padding-bottom 80px', d['scrPadBottom'] == '80px', {'pad': d['scrPadBottom']})
set_state(page, 'results', 0)
page.reload(wait_until='networkidle'); page.wait_for_timeout(1500)
d2 = page.evaluate("""() => [' #g1','#g2','#g3','#g4'].map(s => {
const sp = document.querySelector(s.trim()).querySelector('.swipe');
return {sel: s.trim(), present: !!sp, visible: !!(sp && sp.offsetParent)};
})""")
print(f'{dev} DESKTOP-SWIPE:', json.dumps(d2))
chk(f'{dev}: desktop swipe hidden', all((not o['visible']) for o in d2), d2)
ctx.close()
browser.close()
print('\nVERIFY2-' + ('OK' if not fails else f'FAIL ({len(fails)}): ' + '; '.join(fails)))
sys.exit(0 if not fails else 1)
+42 -8
View File
@@ -322,6 +322,7 @@
else if (p.household === 'couple') ins = 'With pension splitting + household GIS, your joint taxes drop — the panel shows the joint total.'; 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.'; else ins = 'Live numbers — change any answer and this panel moves.';
$('#panel-insight').innerHTML = ins; $('#panel-insight').innerHTML = ins;
syncSheetH(); /* panel content just changed height — keep --sheet-h live (round 2 Fix 1) */
} }
/* ---------- charts (hand-rolled inline SVG) ---------- */ /* ---------- charts (hand-rolled inline SVG) ---------- */
@@ -367,6 +368,25 @@
}).join('') + '</div>'; }).join('') + '</div>';
} }
/* 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 = '<span class="swipe">swipe →</span>';
var CHART_HDR = {
'#g1': '<h3>Will it last? Projected savings</h3><p class="cap">Total portfolio (RRSP + TFSA + non-registered), in today\'s dollars. Shaded: money running out.</p>',
'#g2': '<h3>Where retirement income comes from</h3><p class="cap">CPP, OAS, GIS and what you must withdraw from savings — stacked, after-tax view.</p>',
'#g3': '<h3>Spending vs. what you\'ll actually have</h3><p class="cap">Annual cash in (income after tax &amp; clawback, plus withdrawals) vs. planned spending.</p>',
'#g4': function () { return '<h3>Four futures, one number</h3><p class="cap">Sustainable annual spending that lasts to <span id="lifeexp-lbl">' + state.a.lifeExp + '</span> under each scenario.</p>'; }
};
function chartH(sel) {
var hd = typeof CHART_HDR[sel] === 'function' ? CHART_HDR[sel]() : (CHART_HDR[sel] || '');
return '<div class="chart-h"><div>' + hd + '</div>' + CHART_SWIPE + '</div>';
}
function chart(sel, inner, legendItems) {
$(sel).innerHTML = chartH(sel) + (inner || '') + (legendItems ? legend(legendItems) : '');
}
var COL = { bal: '#2563eb', cpp: '#2563eb', oas: '#dc2626', gis: '#f59e0b', wdr: '#10b981', cash: '#0ea5e9', need: '#dc2626' }; var COL = { bal: '#2563eb', cpp: '#2563eb', oas: '#dc2626', gis: '#f59e0b', wdr: '#10b981', cash: '#0ea5e9', need: '#dc2626' };
function g1_balance(proj) { function g1_balance(proj) {
@@ -385,12 +405,12 @@
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="12" 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]]); chart('#g1', svgWrap(inner), [['Total savings (today’s $)', COL.bal]]);
} }
function g2_income(proj, p) { function g2_income(proj, p) {
var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; }); var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; });
if (!yrs.length) { $('#g2').innerHTML = '<p class="cap">Retirement after your plan horizon.</p>'; return; } if (!yrs.length) { chart('#g2', '<p class="cap">Retirement after your plan horizon.</p>'); return; }
var ages = yrs.map(function (r) { return r.age; }); var ages = yrs.map(function (r) { return r.age; });
var X = sc(ages, ages[0], ages[ages.length - 1]); var X = sc(ages, ages[0], ages[ages.length - 1]);
var series = [ var series = [
@@ -409,12 +429,12 @@
inner += '<path d="' + stackPath(ages, lower, upper, X, Y) + '" fill="' + se[1] + '33" stroke="' + se[1] + '" stroke-width="1"/>'; inner += '<path d="' + stackPath(ages, lower, upper, X, Y) + '" fill="' + se[1] + '33" stroke="' + se[1] + '" stroke-width="1"/>';
lower = upper; lower = upper;
}); });
$('#g2').innerHTML = svgWrap(inner) + legend(series.map(function (s) { return [s[0], s[1]]; })); chart('#g2', svgWrap(inner), series.map(function (s) { return [s[0], s[1]]; }));
} }
function g3_cash(proj) { function g3_cash(proj) {
var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; }); var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; });
if (!yrs.length) { $('#g3').innerHTML = ''; return; } if (!yrs.length) { chart('#g3', ''); return; }
var ages = yrs.map(function (r) { return r.age; }); var ages = yrs.map(function (r) { return r.age; });
var X = sc(ages, ages[0], ages[ages.length - 1]); var X = sc(ages, ages[0], ages[ages.length - 1]);
var inc = yrs.map(function (r) { return r.cash + r.unfunded; }); var inc = yrs.map(function (r) { return r.cash + r.unfunded; });
@@ -428,13 +448,13 @@
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="13" 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]]); chart('#g3', svgWrap(inner), [['Cash you’ll have (after tax)', COL.cash], ['Planned spending', COL.need]]);
} }
var g4cache = null; var g4cache = null;
function g4_bars(p, fast) { function g4_bars(p, fast) {
if (g4cache && fast) { drawG4(g4cache); $('#g4-note').textContent = 'Computed from the current answers.'; return; } if (g4cache && fast) { drawG4(g4cache); $('#g4-note').textContent = 'Computed from the current answers.'; return; }
$('#g4').innerHTML = '<p class="cap">Calculating scenarios…</p>'; chart('#g4', '<p class="cap">Calculating scenarios…</p>');
setTimeout(function () { setTimeout(function () {
var scen = [ var scen = [
['Current plan', p], ['Current plan', p],
@@ -463,7 +483,7 @@
'<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="' + (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="12" 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); chart('#g4', svgWrap(inner));
} }
/* ---------- vision ---------- */ /* ---------- vision ---------- */
@@ -531,7 +551,9 @@
v.innerHTML = 'This plan survives to <b>95</b> with ' + k(proj.finalBal) + ' left over — you can sustain about <b>' + money(ss) + '/yr</b>. ' + v.innerHTML = 'This plan survives to <b>95</b> with ' + k(proj.finalBal) + ' left over — you can sustain about <b>' + money(ss) + '/yr</b>. ' +
'That’s ' + money(ss - p.spendingAnnual) + '/yr of headroom above your plan.'; 'That’s ' + money(ss - p.spendingAnnual) + '/yr of headroom above your plan.';
} }
$('#lifeexp-lbl').textContent = p.lifeExp; /* 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). */
var le = $('#lifeexp-lbl'); if (le) le.textContent = p.lifeExp;
var cards = ''; var cards = '';
if (rec.gapAnnual > 0) { if (rec.gapAnnual > 0) {
@@ -625,6 +647,7 @@
if (h && h.contains(e.target)) { if (h && h.contains(e.target)) {
document.getElementById('panel').classList.toggle('open'); document.getElementById('panel').classList.toggle('open');
syncScrim(); syncScrim();
syncSheetH();
} }
}); });
/* Fix 3: scrim behind the open sheet on mobile; tapping it closes the sheet. */ /* Fix 3: scrim behind the open sheet on mobile; tapping it closes the sheet. */
@@ -635,13 +658,24 @@
scrim.hidden = !open; scrim.hidden = !open;
scrim.classList.toggle('on', open); scrim.classList.toggle('on', open);
} }
/* Round 2 Fix 1: publish the live sheet height as --sheet-h so .scr padding and the sticky
step-nav always clear the sheet (default 218px on :root keeps the first paint right).
Shim-safe: smoke-test stubs have no offsetHeight — the guard skips them. */
function syncSheetH() {
var panel = document.getElementById('panel');
var h = panel && panel.offsetHeight;
if (h) document.documentElement.style.setProperty('--sheet-h', h + 'px');
}
if (window.addEventListener) window.addEventListener('resize', syncSheetH);
var scrimEl = document.getElementById('scrim'); var scrimEl = document.getElementById('scrim');
if (scrimEl) scrimEl.addEventListener('click', function () { if (scrimEl) scrimEl.addEventListener('click', function () {
document.getElementById('panel').classList.remove('open'); document.getElementById('panel').classList.remove('open');
syncScrim(); syncScrim();
syncSheetH();
}); });
var savedScreen = state.screen, savedStep = state.step; var savedScreen = state.screen, savedStep = state.step;
showScreen(savedScreen); showScreen(savedScreen);
if (savedScreen === 'quiz') { renderStep(); liveUpdate(); } if (savedScreen === 'quiz') { renderStep(); liveUpdate(); }
else if (savedScreen === 'results') { renderResults(false); liveUpdate(); } else if (savedScreen === 'results') { renderResults(false); liveUpdate(); }
syncSheetH();
})(); })();
+1 -11
View File
@@ -64,20 +64,10 @@
<div class="cards" id="gap-cards"></div> <div class="cards" id="gap-cards"></div>
<h3>Will it last? Projected savings</h3> <!-- Chart titles/captions render inside each card's .chart-h via app.js chart() (round 2 Fix 2) -->
<p class="cap">Total portfolio (RRSP + TFSA + non-registered), in today's dollars. Shaded: money running out.</p>
<div id="g1" class="chartbox"></div> <div id="g1" class="chartbox"></div>
<h3>Where retirement income comes from</h3>
<p class="cap">CPP, OAS, GIS and what you must withdraw from savings — stacked, after-tax view.</p>
<div id="g2" class="chartbox"></div> <div id="g2" class="chartbox"></div>
<h3>Spending vs. what you'll actually have</h3>
<p class="cap">Annual cash in (income after tax &amp; clawback, plus withdrawals) vs. planned spending.</p>
<div id="g3" class="chartbox"></div> <div id="g3" class="chartbox"></div>
<h3>Four futures, one number</h3>
<p class="cap">Sustainable annual spending that lasts to <span id="lifeexp-lbl">95</span> under each scenario.</p>
<div id="g4" class="chartbox"></div> <div id="g4" class="chartbox"></div>
<p id="g4-note" class="cap"></p> <p id="g4-note" class="cap"></p>
+25 -2
View File
@@ -3,9 +3,13 @@
--ink:#16233b; --ink2:#4c5b74; --line:#e3e8f0; --bg:#f7f9fc; --card:#ffffff; --ink:#16233b; --ink2:#4c5b74; --line:#e3e8f0; --bg:#f7f9fc; --card:#ffffff;
--blue:#2563eb; --red:#dc2626; --amber:#d97706; --green:#059669; --violet:#7c3aed; --blue:#2563eb; --red:#dc2626; --amber:#d97706; --green:#059669; --violet:#7c3aed;
--r:14px; --r:14px;
--muted:#4c5b74; --chip:#eef2f8;
--sheet-h:218px; /* round 2 Fix 1: live bottom-sheet height; app.js keeps it current */
} }
*{box-sizing:border-box} *{box-sizing:border-box}
html{scroll-behavior:smooth} html{scroll-behavior:smooth}
/* Round 2 Fix 3: in-page anchors must land below the sticky header (global; header is sticky on desktop too) */
[id]{scroll-margin-top:70px}
body{margin:0;font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)} body{margin:0;font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)}
main{max-width:1060px;margin:0 auto;padding:12px} main{max-width:1060px;margin:0 auto;padding:12px}
h1{font-size:clamp(26px,6vw,40px);line-height:1.15;margin:.4em 0 .3em} h1{font-size:clamp(26px,6vw,40px);line-height:1.15;margin:.4em 0 .3em}
@@ -25,6 +29,7 @@ button,input{touch-action:manipulation}
/* screens */ /* screens */
.scr{padding:8px 4px 80px} .scr{padding:8px 4px 80px}
.swipe{display:none} /* round 2 Fix 2: hint hidden on desktop; shown ≤860px (rule in the mobile block below — keep it after this line) */
.lede{font-size:18px} .lede{font-size:18px}
.ticks{list-style:none;padding:0;margin:14px 0} .ticks{list-style:none;padding:0;margin:14px 0}
.ticks li{padding:7px 0 7px 30px;position:relative;color:var(--ink2)} .ticks li{padding:7px 0 7px 30px;position:relative;color:var(--ink2)}
@@ -48,8 +53,11 @@ button,input{touch-action:manipulation}
.hdr #btn-print{display:none} .hdr #btn-print{display:none}
/* Fix 2: charts keep desktop scale; scroll sideways on phones */ /* Fix 2: charts keep desktop scale; scroll sideways on phones */
.chartbox{overflow-x:auto;overflow-y:hidden} .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} .chartbox svg{min-width:560px}
/* Round 2 Fix 2: "swipe →" lives in the chart card header (can never overlap SVG ticks);
sticky left:0 keeps it pinned while the card scrolls horizontally */
.chart-h{position:sticky;left:0}
.swipe{display:inline-block;font-size:11.5px;color:var(--muted);background:var(--chip);border-radius:99px;padding:3px 9px;white-space:nowrap;flex:none}
/* Fix 3: sheet height = its content; chips always visible; no transform peek */ /* 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{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 .insight{display:none}
@@ -59,7 +67,18 @@ button,input{touch-action:manipulation}
.panel.open .panel-h::after{content:" ▼"} .panel.open .panel-h::after{content:" ▼"}
#scrim{position:fixed;inset:0;background:#16233b33;z-index:35} #scrim{position:fixed;inset:0;background:#16233b33;z-index:35}
#scrim.on{display:block} #scrim.on{display:block}
.scr{padding-bottom:calc(185px + env(safe-area-inset-bottom))} /* Round 2 Fix 1: clearance = live sheet height (app.js sets --sheet-h) + gap + safe area */
.scr{padding-bottom:calc(var(--sheet-h) + 14px + env(safe-area-inset-bottom))}
/* Round 2 Fix 1: Back/Next rows stick above the sheet on tall steps; solid bg, below scrim(35)/sheet(40).
#btn-next (the primary CTA, last element of the flow) carries the pin at sheet+12px so it is on
screen at every scroll position of every step; .step-nav is pinned per spec and stays in flow
above it. margin-bottom:-6px nets out the base margin so the spec's 6px padding box does not
shift short steps at all (income step stays pixel-identical to round 1: CTA top 485).
At max scroll the doc's own padding + the CTA's 18px margin + 50px height keep the
last option's bottom ≈507 — clear of the CTA top (≈544) and the sheet (≥616). */
.step-nav{position:sticky;bottom:calc(var(--sheet-h) + 14px + env(safe-area-inset-bottom));background:var(--bg);z-index:20;padding:6px 0}
.quiz-main .step-nav{margin-bottom:-6px} /* specificity: base `.step-nav{margin-bottom:6px}` is later in file; net the padding box so round-1 flow is pixel-identical */
#btn-next{position:sticky;bottom:calc(var(--sheet-h) + 12px + env(safe-area-inset-bottom));z-index:21}
} }
@media(max-width:700px){ @media(max-width:700px){
.hdr .pill{display:none} /* privacy pill is repeated on the intro screen and the footer */ .hdr .pill{display:none} /* privacy pill is repeated on the intro screen and the footer */
@@ -106,6 +125,10 @@ 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}
/* Round 2 Fix 2: chart card header = title+caption block with the swipe hint on its right.
.swipe is display:none on desktop (base rule above), so the flex row renders exactly like the old stacked block. */
.chart-h{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}
.chart-h>div{min-width:0}
.legend{display:flex;flex-wrap:wrap;gap:12px;font-size:13px;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}