#!/usr/bin/env python3 """Header lang-toggle right-align audit capture (spec 2026-10-09 header-lang-toggle-rightalign). Usage: python3 audit/lang_right_capture.py BASE OUTDIR TAG [--desktop-twice] BASE e.g. http://127.0.0.1:8899/ OUTDIR audit/lang-right/before | audit/lang-right/after TAG 'before' | 'after' --desktop-twice : also write OUTDIR/../d2 desktop copies (pixdiff self-jitter pair) Writes OUTDIR/geo-TAG.json + screenshots: {dev}-{lang}-{screen}.png for dev in se(320) pixel(360) iphone14(390) desk(1280x900), lang en|fr, screen intro|quiz|results. Acceptance measured in-page: A1 .lang-toggle rightmost in .hdr-in: toggle.right within 2px of (hdrIn.right - paddingRight); no other visible .hdr-in child extends past the toggle's left edge. A2 .hdr-in height <= 56px at <=700px widths. A3 at 320px: hdr-in no overflow, no wrap (single row), logo truncated with ellipsis, toggle + Start over fully visible + elementFromPoint hit-tests. Prints per-case PASS/FAIL lines and CAPTURE-OK / CAPTURE-FAIL. """ import json, os, sys, shutil from playwright.sync_api import sync_playwright BASE = sys.argv[1].rstrip('/') + '/' OUTD = sys.argv[2] TAG = sys.argv[3] TWICE = '--desktop-twice' in sys.argv os.makedirs(OUTD, exist_ok=True) CHROME = '/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome' LS = 'retire-planner-v1' SINGLE = { "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 = [ ('desk', 1280, 900, 1.0, None), ('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'), ] SCREENS = [('intro', 'intro', 0), ('quiz', 'quiz', 0), ('results', 'results', 14)] GEO_JS = r"""() => { const r = e => { const b = e.getBoundingClientRect(); return {l:+b.left.toFixed(1), t:+b.top.toFixed(1), r:+b.right.toFixed(1), b:+b.bottom.toFixed(1), w:+b.width.toFixed(1), h:+b.height.toFixed(1)}; }; const vis = e => { const s = getComputedStyle(e); return s.display !== 'none' && s.visibility !== 'hidden' && !e.hidden; }; const hdrIn = document.querySelector('.hdr-in'); const hi = r(hdrIn), cs = getComputedStyle(hdrIn); const kids = [...hdrIn.children].map(e => ({ tag: e.tagName.toLowerCase(), id: e.id || null, cls: e.className || null, rect: r(e), visible: vis(e), order: getComputedStyle(e).order, })); const tog = kids.find(k => (k.cls || '').includes('lang-toggle')); const padR = parseFloat(cs.paddingRight) || 0; const visibleKids = kids.filter(k => k.visible); const maxRightOther = visibleKids.filter(k => k !== tog).reduce((m, k) => Math.max(m, k.rect.r), -1e9); const pastToggleLeft = visibleKids.filter(k => k !== tog && k.rect.r > tog.rect.l + 0.5) .map(k => (k.id || k.cls) + '@' + k.rect.r); const rightGap = +((hi.r - padR) - tog.rect.r).toFixed(2); // wrap detection: all visible children share one row (compare CENTER-Y, not top edges, // because flex center-aligns children of differing heights on the same row) const cy = visibleKids.map(k => k.rect.t + k.rect.h / 2); const wrapped = cy.length > 0 && (Math.max(...cy) - Math.min(...cy)) > 6; const logo = hdrIn.querySelector('.logo'); const lcs = logo ? getComputedStyle(logo) : null; const hit = sel => { const e = document.querySelector(sel); if (!e || !vis(e)) return {present: !!e, visible: false}; const b = e.getBoundingClientRect(); const cx = Math.round(b.left + b.width / 2), cy = Math.round(b.top + b.height / 2); const top = document.elementFromPoint(cx, cy); return {present: true, visible: true, rect: r(e), inViewport: b.top >= 0 && b.bottom <= innerHeight && b.left >= 0 && b.right <= innerWidth, hit: !!top && (top === e || e.contains(top) || top.contains(e)), hitTag: top ? top.tagName.toLowerCase() + (top.id ? '#' + top.id : '') : null}; }; const hdr = document.querySelector('.hdr'); return { vw: innerWidth, vh: innerHeight, lang: document.documentElement.lang, title: document.title, hdr: hdr ? r(hdr) : null, hdrIn: hi, hdrInPadRight: padR, hdrInH: +hi.h.toFixed(1), scrollW: hdrIn.scrollWidth, clientW: hdrIn.clientWidth, docScrollW: document.documentElement.scrollWidth, flexWrap: cs.flexWrap, kids, toggle: tog, A1_rightGap: rightGap, A1_rightGapOk: Math.abs(rightGap) <= 2, A1_rightmost: tog.rect.r >= maxRightOther - 0.5, A1_pastToggleLeft: pastToggleLeft, A2_hdrH: +hi.h.toFixed(1), wrapped, overflow: hdrIn.scrollWidth > hdrIn.clientWidth + 1 || document.documentElement.scrollWidth > innerWidth + 1, logo: logo ? {truncating: logo.scrollWidth > logo.clientWidth + 1, textOverflow: lcs.textOverflow, ellipsisPossible: lcs.whiteSpace === 'nowrap' && lcs.overflow === 'hidden', rect: r(logo)} : null, hits: {langEn: hit('#lang-en'), langFr: hit('#lang-fr'), restart: hit('#btn-restart')}, }; }""" TOGGLE_JS = r"""async () => { const g = s => document.querySelector(s); const before = {lang: document.documentElement.lang, title: document.title, pe: g('#lang-en').getAttribute('aria-pressed'), pf: g('#lang-fr').getAttribute('aria-pressed')}; g('#lang-fr').click(); await new Promise(r => setTimeout(r, 250)); const fr = {lang: document.documentElement.lang, title: document.title, pe: g('#lang-en').getAttribute('aria-pressed'), pf: g('#lang-fr').getAttribute('aria-pressed')}; g('#lang-en').click(); await new Promise(r => setTimeout(r, 250)); const en = {lang: document.documentElement.lang, title: document.title, pe: g('#lang-en').getAttribute('aria-pressed'), pf: g('#lang-fr').getAttribute('aria-pressed')}; return {before, fr, en, frOk: fr.lang === 'fr' && fr.title !== before.title && fr.pe === 'false' && fr.pf === 'true', backOk: en.lang === 'en' && en.title === before.title && en.pe === 'true' && en.pf === 'false'}; }""" def set_state(page, screen, lang): page.evaluate("""([ls, a, screen, lang]) => { localStorage.setItem(ls, JSON.stringify({a: a, step: (screen==='intro'?0:14), screen: screen, lang: lang})); }""", [LS, SINGLE, screen, lang]) def shot(page, path, full=False): page.screenshot(path=path, full_page=full) def verdicts(key, g, out): """Record acceptance verdicts for one case.""" narrow = g['vw'] <= 700 out.append({'case': key, 'A1_gap': g['A1_rightGap'], 'A1_gapOk': g['A1_rightGapOk'], 'A1_rightmost': g['A1_rightmost'], 'A1_pastLeft': g['A1_pastToggleLeft'], 'A2_hdrH': g['A2_hdrH'], 'A2_ok': (not narrow) or g['A2_hdrH'] <= 56, 'A3_overflow': g['overflow'], 'A3_wrapped': g['wrapped'], 'A3_logoTrunc': bool(g['logo'] and g['logo']['truncating']), 'A3_logoEllipsisOk': (not g['logo'] or not g['logo']['truncating']) or (g['logo']['ellipsisPossible'] and g['logo']['textOverflow'] == 'ellipsis'), 'A3_hits': {k: (v.get('visible', False) and v.get('inViewport', False) and v.get('hit', False)) if k != 'restart' else ((not v.get('present')) or (not v.get('visible')) or (v.get('inViewport') and v.get('hit'))) for k, v in g['hits'].items()}, 'kidsOrder': [(k['id'] or k['cls'], k['order'], k['visible']) for k in g['kids']]}) print(f"[{key}] A1 gap={g['A1_rightGap']}px rightmost={g['A1_rightmost']} pastLeft={g['A1_pastToggleLeft']} | " f"A2 h={g['A2_hdrH']} | A3 overflow={g['overflow']} wrapped={g['wrapped']} logoTrunc={bool(g['logo'] and g['logo']['truncating'])} | " f"hdrH={g['hdrInH']}") geo = {} report = [] fails = [] 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: opts = {'viewport': {'width': w, 'height': h}, 'device_scale_factor': dsf} if ua: opts.update(is_mobile=True, has_touch=True, user_agent=ua) ctx = browser.new_context(**opts) page = ctx.new_page() page.goto(BASE, wait_until='networkidle') for sname, screen, _step in SCREENS: for lang in ('en', 'fr'): key = f'{dev}-{lang}-{sname}' set_state(page, screen, lang) page.reload(wait_until='networkidle') page.wait_for_timeout(1600 if sname == 'results' else 700) shot(page, os.path.join(OUTD, key + '.png')) g = page.evaluate(GEO_JS) geo[key] = g verdicts(key, g, report) if TWICE and dev == 'desk' and sname in ('intro', 'results'): shot(page, os.path.join(OUTD, key + '.d2.png')) if dev == 'iphone14': # toggle test starts from a defined EN state (previous loop iter may leave FR) set_state(page, 'intro', 'en') page.reload(wait_until='networkidle') page.wait_for_timeout(400) geo['toggle-click'] = page.evaluate(TOGGLE_JS) print('toggle-click:', json.dumps(geo['toggle-click'])) ctx.close() browser.close() # ---- verdict summary ---- for row in report: dev = row['case'].split('-')[0] narrow = dev in ('se', 'pixel', 'iphone14') ok = (row['A1_gapOk'] and row['A1_rightmost'] and not row['A1_pastLeft'] and (not narrow or row['A2_ok'])) if dev == 'se': ok = ok and not row['A3_overflow'] and not row['A3_wrapped'] and row['A3_logoEllipsisOk'] and all(row['A3_hits'].values()) if not ok: fails.append(row['case']) tc = geo.get('toggle-click', {}) if tc and not (tc.get('frOk') and tc.get('backOk')): fails.append('toggle-click') with open(os.path.join(OUTD, f'geo-{TAG}.json'), 'w') as f: json.dump({'geo': geo, 'report': report}, f, indent=1, ensure_ascii=False) if TWICE: # desktop pair for self-jitter baseline lives beside the main dir d2 = os.path.join(os.path.dirname(OUTD.rstrip('/')), os.path.basename(OUTD.rstrip('/')) + '2') os.makedirs(d2, exist_ok=True) for f_ in os.listdir(OUTD): if f_.endswith('.d2.png'): shutil.move(os.path.join(OUTD, f_), os.path.join(d2, f_[:-len('.d2.png')] + '.png')) print('cases:', len(geo) - 1, '| failures:', fails if fails else 'NONE') print('CAPTURE-OK' if not fails else 'CAPTURE-FAIL') sys.exit(0 if not fails else 1)