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.
103 lines
4.4 KiB
Python
Executable File
103 lines
4.4 KiB
Python
Executable File
#!/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))
|