Files
booth/scripts/layout-probe.py
vh c3a97c1b64 feat(u4): a booth's lifetime is derived from its state, not from a boolean
`.forever` was the only way to say three different things — "this is durable",
"I have not answered yet", "I am still looking" — and the census said it was
carrying all three: 17 of 24 live booths (70%, up from 54% the day before).
Three of the four booths in the fleet awaiting an answer had been pinned by
hand as well, and 10 of the 17 were younger than the TTL, so the sentinel had
bought them nothing and was pressed pre-emptively.

Only the first meaning is what `keep` means. The other two are facts the
service already held and did not consult.

    KEPT       `.forever` present                      never swept  (unchanged)
    HELD       an open pick, or marks we cannot read   never swept  (new)
    EPHEMERAL  everything else                         24h          (unchanged)

Viewing is activity: a deliberately-served response from a booth's own page
route writes `.viewed`, which is a dotfile and not a `.lock` dotfile, so
`_newest_mtime` already counts it. There is no new arithmetic — `booth_age_seconds`,
`is_expired` and `expires_in` are unchanged. Machine reads are excluded on
purpose: an agent must not be able to hold its own booth open by polling for
the answer it is waiting on.

The hold is unbounded, and what makes that safe is visibility plus two exits
that already existed. Every surface whose chrome the Booth owns says
`held until answered` where the countdown was, and `booth rm` / the UI x /
`DELETE /b/<n>` take a held booth exactly as they take a kept one. A hold is
protection from the timer, never from the operator.

Three cross-frontier panels ran and each found a class the others could not:

  * the paraphrase panel found that two reads of one file are not one read of
    one state — the contract's `is_held(marks_for(c), read_error(c))` could
    resolve to `([], None)`, the pair that deletes. `hold_read` is one read.
  * the code-review panel found, 4-of-4, that the booth header's board branch
    rendered no lifetime at all; and that five of seven invariant tests passed
    under the change that defeats them.
  * the bug-hunt panel found four more paths where a failed read still
    authorized a delete, and a `record_view` that followed a planted symlink.

`is_held` became `hold_reason`, which returns the reason rather than a bool
beside a string that can disagree with it.

Prediction, to re-count on or after 2026-10-06: the `.forever` rate falls to
the booths that are genuinely durable references. Only 4 booths carry marks at
all, so this rests on both halves of the unit; a null result cannot distinguish
a wrong diagnosis from a habit that outlived its need.

406 tests (341 before). Contract: docs/contracts/u4_derived_lifetime.contract.md
2026-09-22 09:44:25 -07:00

144 lines
6.9 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 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/<n>/`
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: <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
# ⚠ OPEN EVERY <details> 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-<details>
# 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))