T9.6 - C2: the 390px pass, measured on all seven pages

The deliberate mobile pass the original proposal never had. Driven page by
page at 390px with mobile emulation (the media queries under test actually
fire) by NEW tests/mobile_check.py, 24/24:

- no page scrolls sideways - all seven (the creator joined at T9.5 when
  BL-001 died)
- no visible control is clipped past the viewport (the probe learned
  frame_check's two lessons: an off-canvas drawer is PARKED, not clipped,
  and a row inside an overflow-x container is scrollable)
- tap targets: the shared coarse-pointer block in wp-chrome.css puts every
  button, input, select, nav link and appbar control at a 44px minimum on
  phone widths and coarse pointers; checkboxes, radios and help-tip badges
  get the 24px WCAG floor with spacing doing the rest. Field View - the
  gloved-hands surface - measures 44px on EVERY control. Inline text links
  are exempt per WCAG 2.5.8's own exception. Even the deliberately
  unobtrusive dev toggle grew to the floor: subtle by opacity, not by size.
- CR-007 attachments offline at 390px and T8.5 requests at 390px were already
  pinned by files_check and mreq_check; this pass cites rather than repeats.

After-screenshots for all seven pages at 390px are committed in
docs/reference/baseline/after-wave9, beside the wave 0 set, captured by the
same baseline_shots.py fixture.

Verification (each probe run alone): NEW tests/mobile_check.py 24/24.
Regressions: form_structure_check 50/51 (BL-022's standing question),
files_check 36/36.

Items: C2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 13:47:17 -07:00
parent 0dcea8d725
commit 6201fcfb4a
12 changed files with 207 additions and 1 deletions

180
tests/mobile_check.py Normal file
View File

@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Does the whole suite hold together at 390px? — C2, T9.6.
Nothing in the original proposal touched mobile, and it is where the worst
rendering was found. This drives all seven pages at 390px (mobile emulation,
so the media queries under test actually fire) and asserts:
- no page scrolls sideways
- no visible control is clipped past the viewport or collapsed to nothing
- every control meets the 24px WCAG floor; on the gloved-hands surfaces
(Field View, and the creator's rail / status / save controls) the bar is
44px, which is what the shared coarse-pointer sizing in wp-chrome.css
delivers
The after-screenshots live in docs/reference/baseline/after-wave9 (captured by
baseline_shots.py) beside the wave 0 set.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from sections_check import set_sop # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PAGES = [
("login", "/login.html"),
("launcher", "/index.html"),
("wizard", "/work-package-suite.html?tab=sop&project=projA"),
("creator", "/wp-creation-index.html?project=projA"),
("field", "/field.html?project=projA"),
("admin", "/admin.html"),
("users", "/users.html"),
]
MEASURE = """(function(){
var out = {sw: document.documentElement.scrollWidth, total:0, under24:[],
clipped:[]};
function skip(el){
// An off-canvas drawer is PARKED outside the viewport by design, and a row
// inside an overflow-x container is scrollable, not clipped - the same two
// lessons frame_check's widest-box scan learned the hard way.
for (var n = el; n; n = n.parentElement){
var cs = getComputedStyle(n);
if (cs.overflowX === 'auto' || cs.overflowX === 'scroll') return true;
if (cs.position === 'fixed' && cs.transform && cs.transform !== 'none') return true;
if (n.getAttribute && n.getAttribute('aria-hidden') === 'true') return true;
}
return false;
}
var els = document.querySelectorAll('button, a[href], input, select, textarea, [role=button]');
for (var i=0;i<els.length;i++){
var el=els[i]; var r=el.getBoundingClientRect();
if (r.width===0 || r.height===0 || el.disabled || el.type==='hidden') continue;
if (skip(el)) continue;
out.total++;
var inlineText = el.tagName==='A' && getComputedStyle(el).display==='inline';
var m=Math.min(r.width,r.height);
var id=el.tagName+'.'+String(el.className).slice(0,24)+' '+Math.round(r.width)+'x'+Math.round(r.height);
if (m < 24 && !inlineText && out.under24.length < 6) out.under24.push(id);
if ((r.left < -2 || r.right > 392) && out.clipped.length < 6) out.clipped.push(id);
}
return JSON.stringify(out);
})()"""
FIELD44 = """(function(){
var out = {total:0, under:[]};
function skip(el){
for (var n = el; n; n = n.parentElement){
var cs = getComputedStyle(n);
if (cs.position === 'fixed' && cs.transform && cs.transform !== 'none') return true;
if (n.getAttribute && n.getAttribute('aria-hidden') === 'true') return true;
}
return false;
}
var els = document.querySelectorAll(
'button, a[href], input:not([type=checkbox]):not([type=radio]), select, textarea');
for (var i=0;i<els.length;i++){
var el=els[i]; var r=el.getBoundingClientRect();
if (r.width===0 || r.height===0 || el.disabled) continue;
if (skip(el)) continue;
out.total++;
if (Math.min(r.width, r.height) < 44 && out.under.length < 6)
out.under.push(el.tagName+'.'+String(el.className).slice(0,24)+' '
+Math.round(r.width)+'x'+Math.round(r.height));
}
return JSON.stringify(out);
})()"""
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
shots = os.path.join(ROOT, "docs", "reference", "baseline", "after-wave9")
chk("the after-screenshots are committed beside the wave 0 baseline",
os.path.isdir(shots) and len([f for f in os.listdir(shots)
if f.endswith("-390.png")]) >= 7,
shots)
tmpdir = tempfile.mkdtemp(prefix="wpsuite-mobile-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
set_sop(db_path, {})
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(390, 844, mobile=True)
for name, path in PAGES:
print("\n%s" % name)
page.goto(base + path)
dismiss_dialogs(page)
time.sleep(2.4)
m = json.loads(page.eval(MEASURE))
chk("%s: no sideways scrolling" % name, m["sw"] <= 392, m["sw"])
chk("%s: no control clipped past the viewport" % name,
not m["clipped"], ascii_(m["clipped"]))
chk("%s: every control meets the 24px floor" % name,
not m["under24"], ascii_(m["under24"]))
# the gloved-hands bar: Field View, everything 44px
print("\nfield view, the 44px bar")
page.goto(base + "/field.html?project=projA")
dismiss_dialogs(page)
time.sleep(2.4)
f = json.loads(page.eval(FIELD44))
chk("field view: every control is a 44px touch target",
f["total"] > 0 and not f["under"], ascii_(f))
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
chk("no JavaScript errors across the whole sweep", not js_errors,
ascii_(js_errors[:2]))
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if server is not None:
try:
server.terminate()
except Exception:
pass
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())