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