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.
This commit is contained in:
@@ -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/`.
|
Then hand the operator `http://10.100.10.50:8090/b/my-run/`.
|
||||||
|
|
||||||
|
## Checking that controls can actually be clicked
|
||||||
|
|
||||||
|
```bash
|
||||||
|
<a python with playwright> 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)
|
## Blurring an item (cosmetic censoring)
|
||||||
|
|
||||||
⚠⚠ **Blur is NOT access control.** A blurred item is still served at its own
|
⚠⚠ **Blur is NOT access control.** A blurred item is still served at its own
|
||||||
|
|||||||
@@ -124,7 +124,13 @@
|
|||||||
.badge-kept{background:var(--aus-blue);color:var(--fg-on-accent)}
|
.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
|
/* 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. */
|
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}
|
.card-kept:hover .release,.release:focus-within{opacity:1}
|
||||||
.release button{font:inherit;font-size:.72rem;line-height:1;padding:.22rem .45rem;
|
.release button{font:inherit;font-size:.72rem;line-height:1;padding:.22rem .45rem;
|
||||||
border-radius:.3rem;cursor:pointer;border:1px solid var(--aus-blue);
|
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}
|
.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
|
/* × on a KEPT card. Same shoulder as the ephemeral ×, deliberately tinted so
|
||||||
it does not read as the same weight of action. */
|
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}
|
/* positioned by .kept-actions, not by itself */
|
||||||
.card-kept:hover .wipe-kept,.wipe-kept:focus-within{opacity:1}
|
|
||||||
.wipe-kept button{font:inherit;line-height:1;cursor:pointer;border:1px solid var(--line);
|
.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)}
|
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);
|
.wipe-kept button:hover{background:var(--aus-red,#ff6b6b);color:var(--fg-on-accent,#fff);
|
||||||
|
|||||||
@@ -56,14 +56,25 @@
|
|||||||
BUMPS the directory mtime, so the board's age resets and it survives
|
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,
|
another full TTL — unkeep-and-wait is a 24h delay, not a delete,
|
||||||
which is exactly why a direct × was worth adding. #}
|
which is exactly why a direct × was worth adding. #}
|
||||||
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
|
{# ⚠ BOTH OF THESE WERE position:absolute ON THE SAME CORNER, and `release`
|
||||||
onsubmit="return confirm('WIPE the KEPT booth \u201c{{ b.name }}\u201d?\n\nThis deletes it and its files immediately. Kept booths are the ones nothing else will clean up, so nobody else is going to do this for you — and nothing brings it back.')">
|
is the later sibling, so it painted over the × completely: measured
|
||||||
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
|
30x22 px of overlap on a 30px button, and elementFromPoint at the ×'s
|
||||||
</form>
|
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. #}
|
||||||
|
<div class="kept-actions">
|
||||||
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
|
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
|
||||||
onsubmit="return confirm('Release \u201c{{ b.name }}\u201d?\n\nIt moves to the ephemeral lane so you can wipe it from there. Nothing is deleted by this step.')">
|
onsubmit="return confirm('Release \u201c{{ b.name }}\u201d?\n\nIt moves to the ephemeral lane so you can wipe it from there. Nothing is deleted by this step.')">
|
||||||
<button title="release this board so it can be wiped">release</button>
|
<button title="release this board so it can be wiped">release</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
|
||||||
|
onsubmit="return confirm('WIPE the KEPT booth \u201c{{ b.name }}\u201d?\n\nThis deletes it and its files immediately. Kept booths are the ones nothing else will clean up, so nobody else is going to do this for you — and nothing brings it back.')">
|
||||||
|
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Executable
+102
@@ -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
|
||||||
|
<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))
|
||||||
Reference in New Issue
Block a user