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:
1 parent
53315e03e8
commit
1b194b6f92
37 files changed
+1007
-21
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user