diff --git a/services/booth/README.md b/services/booth/README.md index 0abb538..4a9210f 100644 --- a/services/booth/README.md +++ b/services/booth/README.md @@ -39,6 +39,35 @@ rsync -a ./out/ nh3-dev:booth-data/my-run/ Then hand the operator `http://10.100.10.50:8090/b/my-run/`. +## Checking that controls can actually be clicked + +```bash + scripts/layout-probe.py [URL ...] +``` + +⚠ **Markup inspection structurally cannot catch occlusion, and this UI has +shipped two dead controls in two days** — a reveal button whose handler Jinja +discarded, and a `×` that a sibling `release` form painted over completely +(30x22 px overlap on a 30px button; `elementFromPoint` at its centre returned +the other form). Both were reported by the operator. Both passed every test, +because the markup, the routes and the CSS were each individually correct. + +The probe walks every button and link, scrolls it into view, and asks the +browser what a click at its centre would actually hit. It took four iterations +to become trustworthy, and each failure is worth knowing because they are the +traps in writing this kind of check at all: + +1. `top.contains(el)` counted an **ancestor** overlay as a hit — which is the + exact case the probe exists to catch. It reported OK for a real overlay. +2. `elementFromPoint` is **viewport-relative**, so everything below the fold + read as occluded. Scroll first. +3. `getBoundingClientRect()` on a **wrapped inline** element is the union of + its line boxes, whose centre can sit in the gutter between them, on the + parent. Use `getClientRects()[0]`. +4. Only after all three does the positive control (a real overlay) fire while + the negative control (the clean page) stays silent. **Both were run.** A + probe that has never been seen to fail is not evidence of anything. + ## Blurring an item (cosmetic censoring) ⚠⚠ **Blur is NOT access control.** A blurred item is still served at its own diff --git a/services/booth/booth/templates/base.html b/services/booth/booth/templates/base.html index ffc6bef..c6bfe5b 100644 --- a/services/booth/booth/templates/base.html +++ b/services/booth/booth/templates/base.html @@ -124,7 +124,13 @@ .badge-kept{background:var(--aus-blue);color:var(--fg-on-accent)} /* Release sits where the ephemeral card's × sits, but reads as a word rather than a destructive glyph — it is not the delete, it is what unlocks it. */ - .release{position:absolute;top:.4rem;right:.4rem;opacity:0;transition:opacity .12s} + /* One positioned row holds BOTH kept-card controls. They used to pin + themselves to the same corner independently and the later sibling won. */ + .kept-actions{position:absolute;top:.4rem;right:.4rem;display:flex;gap:.3rem; + align-items:center;opacity:0;transition:opacity .12s} + .card-kept:hover .kept-actions,.kept-actions:focus-within{opacity:1} + .kept-actions form{position:static;opacity:1;margin:0} + .release{opacity:0;transition:opacity .12s} .card-kept:hover .release,.release:focus-within{opacity:1} .release button{font:inherit;font-size:.72rem;line-height:1;padding:.22rem .45rem; border-radius:.3rem;cursor:pointer;border:1px solid var(--aus-blue); @@ -255,8 +261,7 @@ .keepit{position:absolute;top:.5rem;left:.5rem;margin:0;opacity:0;transition:opacity .12s} /* × on a KEPT card. Same shoulder as the ephemeral ×, deliberately tinted so it does not read as the same weight of action. */ - .wipe-kept{position:absolute;top:.5rem;right:.5rem;margin:0;opacity:0;transition:opacity .12s} - .card-kept:hover .wipe-kept,.wipe-kept:focus-within{opacity:1} + /* positioned by .kept-actions, not by itself */ .wipe-kept button{font:inherit;line-height:1;cursor:pointer;border:1px solid var(--line); border-radius:.3rem;padding:.02rem .3rem;background:var(--bg);color:var(--muted)} .wipe-kept button:hover{background:var(--aus-red,#ff6b6b);color:var(--fg-on-accent,#fff); diff --git a/services/booth/booth/templates/index.html b/services/booth/booth/templates/index.html index 3e58a2b..5b397de 100644 --- a/services/booth/booth/templates/index.html +++ b/services/booth/booth/templates/index.html @@ -56,14 +56,25 @@ BUMPS the directory mtime, so the board's age resets and it survives another full TTL — unkeep-and-wait is a 24h delay, not a delete, which is exactly why a direct × was worth adding. #} -
- -
+ {# ⚠ BOTH OF THESE WERE position:absolute ON THE SAME CORNER, and `release` + is the later sibling, so it painted over the × completely: measured + 30x22 px of overlap on a 30px button, and elementFromPoint at the ×'s + centre returned the release form. The × was unclickable from the day + it shipped. + + One flex row, positioned once, instead of two independently guessed + offsets — so neither control can drift back on top of the other when + a label changes width. #} +
+
+ +
+
{% endfor %} diff --git a/services/booth/scripts/layout-probe.py b/services/booth/scripts/layout-probe.py new file mode 100755 index 0000000..1dc8ae1 --- /dev/null +++ b/services/booth/scripts/layout-probe.py @@ -0,0 +1,102 @@ +#!/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 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: 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))