Files
retire-planner/audit/measure2_run.py
T
krisf 65863c0302 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.
2026-10-09 06:44:08 -04:00

99 lines
5.6 KiB
Python

"""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')