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))