Header: right-align EN|FR toggle in .hdr-in (top-right of page)
One CSS line: .lang-toggle{order:99;margin-left:auto}. Spec's literal order:99 alone
failed its own <=2px right-edge acceptance (gap 45.6-467.6px, 18/24 cases — order
reorders, .hdr-in is left-packed; geo-literal-order99.json), so margin-left:auto on
the toggle absorbs the free space; pill/logo untouched (spec-forbidden zone respected).
Battery: A1 gap=0px + rightmost + nothing-past-left in 24/24 (320/360/390/1280 x EN/FR
x intro/quiz/results); A2 header height unchanged 56/66px; A3 320px no overflow/wrap,
FR logo ellipsis intact, elementFromPoint hit-tests lang buttons + Start over pass;
A4 FR/EN clicks switch lang+title+aria-pressed; A5 24 screenshots before/after; A6
engine 36 + i18n 48 + smoke 92 + ps-pension 70 green; A7 desktop pixdiff header-strip-
only, 0 px below header at every device, self-jitter bit-identical; A8 DEPLOY-OK,
origin marker=1 + byte-identical CSS, off-LAN troublebox 200+marker+rule OFFLAN-OK.
This commit is contained in:
1 parent
4c8fcad8ad
commit
79c40a7b9d
11 files changed
+20636
No files matched your search
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"http_code": "200",
|
||||
"has_marker": true,
|
||||
"css_lang_toggle_right": true,
|
||||
"css_size": 13571,
|
||||
"checked_at": "2026-10-09T17:31:08Z",
|
||||
"vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)",
|
||||
"all_ok": true
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,221 @@
|
||||
#!/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/../<basename>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)
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Off-LAN check for the header lang-toggle right-align deploy (2026-10-09),
|
||||
via the Midhurst troublebox. Verifies 200 + marker + served style.css carries the
|
||||
`.lang-toggle{order:99;margin-left:auto}` rule. Writes audit/lang-right-geo-check.json,
|
||||
prints OFFLAN-OK."""
|
||||
import json, sys, time
|
||||
import paramiko
|
||||
|
||||
CANDS = ["b1tvvt07", "ubnt", "admin", "password", "b1tvvt07!", "1234", "root", "glinet"]
|
||||
URL = "https://retire.krisforbes.ca/"
|
||||
|
||||
def main():
|
||||
conn = None
|
||||
for pw in CANDS:
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
c.connect("100.96.20.61", port=2222, username="trouble", password=pw,
|
||||
timeout=25, banner_timeout=25, auth_timeout=25,
|
||||
allow_agent=False, look_for_keys=False)
|
||||
conn = c
|
||||
break
|
||||
except Exception:
|
||||
c.close()
|
||||
if conn is None:
|
||||
print("OFFLAN-FAIL: no auth to troublebox")
|
||||
sys.exit(1)
|
||||
|
||||
def run(cmd):
|
||||
_, out, _ = conn.exec_command(cmd, timeout=60)
|
||||
return out.read().decode("utf-8", "replace").strip()
|
||||
|
||||
code = run(f"curl -s -o /dev/null -w '%{{http_code}}' {URL} --max-time 30")
|
||||
html = run(f"curl -s {URL} --max-time 30 | head -c 200000")
|
||||
css = run(f"curl -s {URL}style.css --max-time 30 | head -c 200000")
|
||||
conn.close()
|
||||
|
||||
checks = {
|
||||
"http_code": code,
|
||||
"has_marker": "retire-planner v1" in html,
|
||||
"css_lang_toggle_right": "lang-toggle{order:99;margin-left:auto}" in css,
|
||||
"css_size": len(css),
|
||||
"checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)",
|
||||
}
|
||||
checks["all_ok"] = (code == "200" and checks["has_marker"] and checks["css_lang_toggle_right"])
|
||||
with open("audit/lang-right-geo-check.json", "w") as f:
|
||||
json.dump(checks, f, indent=2, ensure_ascii=False)
|
||||
print(json.dumps(checks, indent=2, ensure_ascii=False))
|
||||
print("OFFLAN-OK" if checks["all_ok"] else "OFFLAN-FAIL")
|
||||
sys.exit(0 if checks["all_ok"] else 1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Desktop pixel-diff for the header lang-toggle move (spec acceptance 7).
|
||||
|
||||
Compares two screenshot dirs (intro/results, EN+FR) and proves the ONLY change is
|
||||
inside the header strip: the diff bbox must sit entirely above `hdr_bottom` (masked),
|
||||
and everything below the header must be bit-identical. Prints a self-jitter baseline
|
||||
DIR_A vs DIR_A2 (same version captured twice) for the jitter-controlled comparison.
|
||||
|
||||
Usage: python3 audit/lang_right_pixdiff.py BEFORE AFTER HDR_BOTTOM
|
||||
python3 audit/lang_right_pixdiff.py SELFJITTER DIR DIR2 HDR_BOTTOM
|
||||
"""
|
||||
import os, sys
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
# Usage: pixdiff.py BEFORE AFTER HDR_BOTTOM [PREFIX]
|
||||
# pixdiff.py SELFJITTER DIR DIR2 HDR_BOTTOM [PREFIX]
|
||||
if sys.argv[1] == 'SELFJITTER':
|
||||
cross = False
|
||||
A, B, HB = sys.argv[2], sys.argv[3], int(sys.argv[4])
|
||||
PREFIX = sys.argv[5] if len(sys.argv) > 5 else None
|
||||
else:
|
||||
cross = True
|
||||
A, B, HB = sys.argv[1], sys.argv[2], int(sys.argv[3])
|
||||
PREFIX = sys.argv[4] if len(sys.argv) > 4 else None
|
||||
|
||||
fa = sorted(f for f in os.listdir(A) if f.endswith('.png') and (PREFIX is None or f.startswith(PREFIX)))
|
||||
zero, all_in_hdr = True, True
|
||||
for f in fa:
|
||||
p = os.path.join(A, f)
|
||||
q = os.path.join(B, f)
|
||||
if not os.path.exists(q):
|
||||
if cross:
|
||||
print(f'{f}: MISSING in {B}'); zero = False; all_in_hdr = False
|
||||
else:
|
||||
print(f'{f}: (no self-jitter pair, skipping)')
|
||||
continue
|
||||
ia, ib = Image.open(p).convert('RGB'), Image.open(q).convert('RGB')
|
||||
if ia.size != ib.size:
|
||||
print(f'{f}: SIZE DIFF {ia.size} vs {ib.size}'); zero = False; all_in_hdr = False; continue
|
||||
W, H = ia.size
|
||||
d = ImageChops.difference(ia, ib)
|
||||
bbox = d.getbbox()
|
||||
if bbox is None:
|
||||
print(f'{f}: 0 px (identical)'); continue
|
||||
x0, y0, x1, y1 = bbox
|
||||
# mask the header strip: crop everything below HB and diff that
|
||||
below = ImageChops.difference(ia.crop((0, HB, W, H)), ib.crop((0, HB, W, H)))
|
||||
below_bbox = below.getbbox()
|
||||
hist = d.convert('L').histogram()
|
||||
ndiff = sum(hist[1:])
|
||||
below_hist = below.convert('L').histogram()
|
||||
ndiff_below = sum(below_hist[1:])
|
||||
if ndiff_below > 0:
|
||||
print(f'{f}: {ndiff} px total, {ndiff_below} px BELOW header (bbox {bbox}) <-- LEAK')
|
||||
zero = False; all_in_hdr = False
|
||||
else:
|
||||
tag = 'header-only' if cross else 'identical'
|
||||
print(f'{f}: {ndiff} px total, 0 px below y={HB}, bbox_top={y0} bbox_bot={y1} [{tag}]')
|
||||
if not cross:
|
||||
zero = zero and (ndiff == 0)
|
||||
print('---')
|
||||
if cross:
|
||||
print('PIXDIFF-HEADER-ONLY' if all_in_hdr else 'PIXDIFF-LEAK-BELOW-HEADER')
|
||||
sys.exit(0 if all_in_hdr else 1)
|
||||
else:
|
||||
print('SELFJITTER-ZERO' if zero else 'SELFJITTER-NONZERO')
|
||||
sys.exit(0 if zero else 1)
|
||||
Reference in new issue
Block a user