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 b209a19ae3
commit 043fa8a683
11 files changed
+20636

No files matched your search

+1
View File
@@ -5,3 +5,4 @@ node_modules/
site/vision/*.jpeg
.tmp/
audit/ps-pension/*.png
audit/lang-right/**/*.png
+115
View File
@@ -0,0 +1,115 @@
# HEADER-LANG-RIGHT REPORT — EN|FR toggle right-aligned in `.hdr-in` (top-right of page)
**Run: 2026-10-09, DSH.** Spec: `/home/ubuntu/projects/.hermes/plans/2026-10-09_header-lang-toggle-rightalign.md` (v1).
Baseline: `main @ 4c8fcad` (PS-pension stage B + Hermes post-deploy verification), tree clean, tests green.
Result: **SHIPPED** — deployed, origin + off-LAN verified, committed and pushed.
---
## 1. The change
One added line in `site/style.css` (top level, directly after the existing `.lang-toggle` rule at line 27):
```css
.lang-toggle{order:99;margin-left:auto} /* right-align the EN|FR toggle inside .hdr-in ... */
```
No DOM, JS, or other-CSS changes. `git diff site/` = 1 insertion, 0 deletions.
### Deviation from the spec's literal rule (measured, justified)
The spec preferred `.lang-toggle{order:99}` alone. Applied literally, it **failed the spec's own
acceptance 1** in 18/24 cases: `order` reorders flex items but `.hdr-in` is left-packed (no
`justify-content`), so the toggle became the rightmost element yet sat flush against *Start over*,
`order:99` alone → gap (toggle.right → hdrIn.right−padR) 188.1–467.6px on desktop, 45.6–160px on mobile
(full matrix in `audit/lang-right/geo-literal-order99.json` — re-measured post-hoc against a site copy
carrying the literal rule; the `after/` dir is the shipped rule).
The spec's DOM-move fallback fails the same ≤2px criterion for the same reason (a last child packs
against the previous item). The spec's "order misbehaves" escape clause covers this:
added `margin-left:auto` on the toggle itself — the auto margin absorbs the free space so the toggle
sits flush at the header's right padding edge. The spec only forbade `margin-left:auto` on the
**pill/logo** ("leave the other elements left-packed exactly as today") — untouched, verified:
all non-header-strip pixels on desktop are bit-identical (section 4).
Resulting visual order = exactly the spec's drawing: `[logo] [pill] [Start over] ………… [EN|FR]` desktop;
`[logo] [Start over] …… [EN|FR]` at ≤700px.
## 2. Geometry — acceptance 1/2/3 (24 cases: 320/360/390/1280 × EN/FR × intro/quiz/results)
Measured with `getBoundingClientRect` via Playwright (`audit/lang_right_capture.py`); `gap` =
`(hdrInRect.right − paddingRight) − toggleRect.right`, spec tolerance ≤2px.
| case group | gap BEFORE | gap AFTER | rightmost AFTER | other elements past toggle-left AFTER |
|---|---|---|---|---|
| desk 1280×900 (intro/quiz/results × EN/FR) | 733.7–807.4px | **0.0px** | ✓ 6/6 | none (6/6) |
| iphone14 390 | 95–160px | **0.0px** | ✓ 6/6 | none |
| pixel 360 | 65–130px | **0.0px** | ✓ 6/6 | none |
| se 320 | 25–112.5px | **0.0px** | ✓ 6/6 | none |
- **A1** ✓ every case: gap 0px (≤2px), toggle is the rightmost visible `.hdr-in` child, no other
element's rect extends past the toggle's left edge. Computed `order`: toggle 99, all siblings 0.
- **A2** ✓ header height **unchanged**: 56px at 320/360/390 (≤56 required) and 66px at 1280, identical
before and after (the `.hdr-in{flex-wrap:nowrap;padding:5px 10px}` mobile rule keeps holding).
- **A3** ✓ at 320px: `scrollWidth == clientWidth == 320` (no overflow), zero wrap at every width
(center-Y row test), `elementFromPoint` center hit-tests pass for `#lang-en`, `#lang-fr` and
`#btn-restart` on every screen where they are shown (restart is `hidden` on intro by design).
Logo continuity: EN logo fits 320px (124.6px wide, not truncating — identical to before); FR
"Planificateur de retraite" truncates on quiz/results at exactly 102.1px with
`text-overflow:ellipsis` (`ellipsisPossible: true`), same as before.
## 3. Toggle behavior — acceptance 4 ✓
In-browser click test at 390px (`toggle-click` block in both geo JSONs, before and after identical):
- FR click → `documentElement.lang === 'fr'`, title → “Planificateur de retraite — la retraite
canadienne, modélisée comme il faut”, `#lang-en` aria-pressed=false, `#lang-fr` true.
- EN click → back: lang `en`, original title, aria-pressed true/false. `frOk: true, backOk: true`.
## 4. Screenshots + desktop regression — acceptance 5 & 7 ✓
24 screenshots per version (`{se,pixel,iphone14,desk}-{en,fr}-{intro,quiz,results}.png`) in
`audit/lang-right/before/` and `audit/lang-right/after/`; self-jitter pairs in `before2/`/`after2/`.
PNGs are gitignored (ps-pension precedent); files on disk for Hermes re-verification; geo JSONs are committed.
Jitter-controlled pixdiff (`audit/lang_right_pixdiff.py`, PIL `ImageChops`):
- **Self-jitter**: before vs before2 and after vs after2, 1280×900 intro/results EN+FR:
**0 px, bit-identical** both times → capture pipeline adds no noise; cross-version diffs are trustworthy.
- **Cross-version before↔after**: every diff bbox sits inside the header strip
(desk: bbox y 10–56, header 66px; **0 changed px below y=66**). Mobile re-checked with
DPR-scaled masks (56 CSS-px header → 114/157/171 device-px at dsf 2/2.75/3):
**0 px below the header strip at every device** — page body pixel-identical below the header on all
24 before/after pairs.
## 5. Tests — acceptance 6 ✓ (green at preflight and after the change)
| suite | result |
|---|---|
| `node tests/engine.test.js` | **36 pass, 0 fail** |
| `node tests/i18n.test.js` | **I18N-OK (48)** |
| `node tests/ui.smoke.js` | **SMOKE-OK (92)** — spec said 56; the suite has grown since spec time (PS rounds), gate is “green”, met |
| `node tests/ps-pension.test.js` | **70 pass, 0 fail** |
## 6. Deploy — acceptance 8 ✓
- `bash scripts/deploy.sh` → **DEPLOY-OK**
- Origin (`http://192.168.0.123:8103/`): marker count **1**; new rule present **1×**; served
`style.css` **byte-identical** to local.
- Off-LAN (Midhurst troublebox, `audit/lang_right_offlan.py` → `audit/lang-right-geo-check.json`):
**200** + marker ✓ + served CSS carries `lang-toggle{order:99;margin-left:auto}` ✓ → **OFFLAN-OK**
(2026-10-09T17:31:08Z).
## 7. Files
- `site/style.css` — the one-line fix (the only site change).
- `audit/lang_right_capture.py` — capture + acceptance 1–4 harness (A3 gates: ellipsis-clean truncation
when present; hidden `#btn-restart` on intro is not a hit-test failure).
- `audit/lang_right_pixdiff.py` — header-strip-masked pixdiff + self-jitter.
- `audit/lang_right_offlan.py` — off-LAN troublebox check.
- `audit/lang-right/{before,before2,after,after2}/` — screenshots (on disk; PNGs gitignored),
`geo-before.json` / `geo-after.json` (full 24-case measurements + toggle-click test),
`geo-before-order99.json` (literal-spec-rule evidence).
- `audit/lang-right-geo-check.json` — off-LAN proof.
## 8. Commit
`main`: `site/style.css` one-liner + audit scripts/JSONs, pushed to `origin/main`.
HEADER_LANG_DONE
+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)
+1
View File
@@ -25,6 +25,7 @@ b{color:var(--ink)}
.pill{font-size:12px;background:#e8f5ee;color:#0a5c36;border:1px solid #bfe6d0;border-radius:99px;padding:3px 10px;color:#0a5c36}
/* language toggle: compact EN|FR pill group; 44px touch targets (mobile pass rules) */
.lang-toggle{display:inline-flex;border:1px solid var(--line);border-radius:99px;overflow:hidden;flex:none;background:var(--card)}
.lang-toggle{order:99;margin-left:auto} /* right-align the EN|FR toggle inside .hdr-in (flex order=last visually; margin-left:auto absorbs the free space so it sits flush at the header's right padding edge — order alone leaves it left-packed next to Start over; other children stay left-packed untouched) */
.lang-btn{padding:4px 10px;border:0;background:transparent;font-size:13px;font-weight:700;color:var(--ink2);cursor:pointer;min-height:44px;line-height:1.2}
.lang-btn[aria-pressed="true"]{background:var(--blue);color:#fff}
.logo{font-size:17px;min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}