#!/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 scripts/layout-probe.py [URL ...] Exits 0 if every control is hittable, 1 if any is occluded. No arguments probes the INDEX ONLY — it does not follow booth links, and the docstring claimed it did until 2026-09-22. Pass booth URLs explicitly to cover them: scripts/layout-probe.py http://10.100.10.50:8090/{,b/my-run/} ⚠ PROBING A BOOTH PAGE RESETS THAT BOOTH'S TTL CLOCK (U4). A GET of `/b//` is a view, and a view is activity — that is the rule, and this script is not exempt from it just because it is ours. Sweeping every booth page therefore buys every booth another full TTL. Harmless and recoverable (nothing is deleted, things merely live longer), named here so nobody debugs it later as a sweeper that stopped working. The index-only default does NOT do this: browsing the index is deliberately not a view. ⚠ In zsh an unquoted `$URLS` does NOT word-split, so a variable holding several URLs arrives as ONE argument and the probe silently reports "2 page(s)" while covering two. Use an array and `"${URLS[@]}"`. """ 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: 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 # ⚠ OPEN EVERY
FIRST. A control inside a CLOSED one is laid out # but sits outside its collapsed parent's box, so `elementFromPoint` at its # centre returns an ancestor and it reports OCCLUDED — 23 of them on # `sindra-set`, every one a false positive, because the only way an operator # reaches that button is by opening the disclosure first. Verified both # ways: closed -> elementFromPoint returns div.gallery; opened -> the button # itself, and a real trial click lands on it. # # Opening rather than SKIPPING is deliberate. Skipping would make the probe # quiet by declaring put-away controls out of scope, and the add-note button # inside `details.item-addnote` is exactly the kind of control this # instrument exists to check. Open it and ask the real question. # # ⚠ ONE evaluate over the whole document, NOT a locator loop. `.all()` hands # back positional locators that re-resolve against the CURRENT DOM, and # `details:not([open])` stops matching an element the moment it is opened — # so opening them one at a time shrinks the set underneath the indices and # some are never opened at all. That left exactly the closed-
# false positives this block exists to remove: 1 on booth-redesign, 3 on # cr123a-to-d-sleeve, stable across five runs and invisible as a bug # because a false positive looks like a finding. Measured both ways at # 150 ms and 1000 ms settle: the loop reports them at either wait, the # single pass reports none at either. The variable was the method, not the # timing. page.evaluate("document.querySelectorAll('details').forEach(d => d.open = true)") page.wait_for_timeout(150) 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))