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:
krisf committed 2026-10-09 13:32:40 -04:00
1 parent 4c8fcad8ad
commit 79c40a7b9d
11 files changed
+20636

No files matched your search

+9
View File
@@ -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
+221
View File
@@ -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)
+54
View File
@@ -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()
+67
View File
@@ -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)