Files
esh-pfi-infrastructure/services/booth/scripts/layout-probe.py
T
vh 07c9cb293b fix(booth): release was painting over the delete ×, which was unclickable
Operator: "release button covers delete button". Measured before touching
anything: release 58x24 at (323,266), × 30x30 at (349,268) — 30x22 px of
overlap on a 30px button, and `elementFromPoint` at the ×'s centre returned the
release form. The × I added yesterday was 100% unclickable from the moment it
shipped.

Cause: both were `position:absolute` on the same corner, each with its own
guessed offset, and `release` is the later sibling so it won. Replaced with one
flex row positioned once — release left, × right at the card corner where the
ephemeral lane's × already lives, so muscle memory transfers and neither can
drift back on top of the other when a label changes width.

Verified by measurement, not inspection: overlap 0 px, and clicks at each
control's centre now land on that control. The ephemeral lane's × and ★ were
re-checked and are unaffected.

ADDS scripts/layout-probe.py, because markup inspection STRUCTURALLY cannot see
this and I have now shipped two dead controls in two days by reading templates
instead of rendering them. It asks a real browser what a click at each
control's centre would hit.

It took four iterations to become trustworthy and the failures are the point:

  1. `top.contains(el)` counted an ANCESTOR overlay as a hit -- the exact case
     it exists to catch. Version one reported OK for a real overlay.
  2. elementFromPoint is viewport-relative, so everything below the fold read
     as occluded and buried the real findings.
  3. getBoundingClientRect() on a WRAPPED INLINE element is the union of its
     line boxes, whose centre lands in the gutter between them -- three zip
     links reported occluded by their own parent. Real geometry, wrong question.

Only the fourth version fires on a genuine overlay while staying silent on the
clean page. Both controls were run; a probe never seen to fail proves nothing.

173 tests pass.
2026-09-21 08:54:56 -07:00

103 lines
4.4 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""layout-probe — find controls that render but cannot be clicked.
WHY THIS EXISTS. On 2026-09-21 the operator reported "release button covers
delete button". Both controls were `position:absolute` on the same corner of a
kept card, and `release` was the later sibling, so it painted over the × with
30x22 px of overlap on a 30px button. `elementFromPoint` at the ×'s centre
returned the release form: the × was 100% unclickable from the day it shipped.
Nothing in the test suite could have caught it. The markup was correct, the
route was correct, the CSS was individually valid. OCCLUSION IS A PROPERTY OF
THE RENDERED LAYOUT, and the only instrument that sees it is a browser.
This is the second control shipped inert in two days — the first was a reveal
button whose handler Jinja discarded. Both were reported by the operator, both
would have taken ten seconds to catch by looking at the page.
USAGE
<a python with playwright> scripts/layout-probe.py [URL ...]
Exits 0 if every control is hittable, 1 if any is occluded. No arguments
probes the booth index and every booth linked from it.
"""
import sys
from playwright.sync_api import sync_playwright
DEFAULT = "http://10.100.10.50:8090/"
# Does a click at this element's centre actually reach it?
HIT = """(el) => {
// ⚠ Use getClientRects()[0], NOT getBoundingClientRect(). For an INLINE
// element that WRAPS, the bounding rect is the union of its line boxes and
// its geometric centre can land in the gutter between lines — on the parent,
// not on the element. The third version of this probe reported three zip
// links as OCCLUDED for exactly that reason: long booth names wrapped the
// link, and `elementFromPoint` correctly returned the parent .sub div. Real
// geometry, wrong question. Per-line rects ask the right one.
const rects = el.getClientRects();
const r = rects.length ? rects[0] : el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return 'ZERO-SIZE';
const x = r.x + r.width / 2, y = r.y + r.height / 2;
if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return 'OFF-SCREEN';
const top = document.elementFromPoint(x, y);
if (!top) return 'OFF-SCREEN';
// `top.contains(el)` is NOT a hit and must never be added back. An ANCESTOR
// receiving the click is precisely what occlusion looks like when the
// overlay is a parent or a parent's ::after, and an ancestor trivially
// contains its descendant — that clause made version one report OK for a
// real overlay. A DESCENDANT receiving it is fine: <a><img> resolves to the
// img and the anchor still gets the click.
return (el === top || el.contains(top)) ? 'OK' : 'OCCLUDED';
}"""
def probe(page, url: str) -> list[str]:
bad = []
page.goto(url, wait_until="networkidle")
# Hover every card first: these UIs reveal controls on hover, and an
# opacity-0 control still occupies layout and still occludes.
for card in page.locator("article.card").all():
try:
card.hover(timeout=1500)
except Exception:
pass
for el in page.locator("button, a.dl-link, a.thumb").all():
try:
# ⚠ elementFromPoint is VIEWPORT-relative. Without scrolling first,
# every control below the fold reports OCCLUDED and the probe
# drowns its real findings in noise — which is what the second
# version did on a page with sixteen kept booths.
el.scroll_into_view_if_needed(timeout=1500)
verdict = el.evaluate(HIT)
except Exception:
continue
if verdict in ("OCCLUDED", "ZERO-SIZE"):
label = (el.get_attribute("aria-label")
or el.get_attribute("title")
or (el.text_content() or "").strip()[:30] or "?")
bad.append(f"{url} {verdict:<10} {label}")
return bad
def main(argv: list[str]) -> int:
urls = argv[1:] or [DEFAULT]
failures = []
with sync_playwright() as pw:
b = pw.chromium.launch()
pg = b.new_page(viewport={"width": 1400, "height": 900})
for u in urls:
failures += probe(pg, u)
b.close()
if failures:
print("UNCLICKABLE CONTROLS:")
for f in failures:
print(" ", f)
return 1
print(f"all controls hittable across {len(urls)} page(s)")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))