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