`.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
144 lines
6.9 KiB
Python
Executable File
144 lines
6.9 KiB
Python
Executable File
#!/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))
|