fix(as-S5c): whose key it is, the doc bar, tile sizes, the rail's shadow, reveal names

The last of the anti-slop interaction work (guidelines G6, G7, G14, G15,
G17), plus booth-dev's note from S5b's gate. Every S5b promise holds: no
re-POST, serialized saves, a batch never reloads, focus survives a swap.
- Keys (G6): one rule in base.html's <head>, BoothKeys.theirs(e), called
  first by the grid, the review and compare. A field or a player owns every
  key but Escape (Esc still goes back from a focused player); a control
  owns Space; a focused 1:1 stage that can pan owns the
  arrows and Space (Chromium puts it in the Tab order); Ctrl/Meta/Alt are
  the browser's. The field check lives on as BoothKeys.isEditable. Before:
  an arrow on a focused video left the review, and Enter on any control
  also opened the grid cursor's tile.
- The grid cursor is real focus: the tile it moves to gets tabindex=-1
  (script-set, one tile at a time) and focus, without a scroll; the cursor
  is an item (its data-item), and a doc closed with its ✕ is skipped; focus that
  lands on a tile (S5b's fallback) makes it the cursor; Enter opens the
  review only from the body, the grid or the tile, by its view?f= link;
  n opens a closed doc's fold; Escape clears the cursor
  and releases the tile's focus. The reticle is its focus mark (no second
  ring).
- The doc bar (G7): the controls leave the <summary>. div.doc-bar holds
  details.doc-fold (its summary is the label only) and div.doc-tools beside
  it; the body and notes follow in div.doc-inline, hidden with a closed
  fold by :has(), scripts on or off. A closed doc keeps its tools. Renders
  pixel-identical to today at 1280 and 390, light and dark.
- Tile sizes (G14): a gallery tile's <img> carries width/height, the
  picture as the browser draws it (EXIF 5-8 swap), read from the header
  only (no decode; PNG getexif is skipped unless the header carried it),
  opened O_NOFOLLOW|O_NONBLOCK, cached by the file's identity (ctime
  included, so cp -p over a file is seen), in a separate
  step (items.image_dims over thumbs.drawn_size) so the Desk never pays it.
  Measured before: a link to tile 30 of 40 landed 44px low (3/3); after, on
  its mark. content-visibility:auto, which the report proposed too, is NOT
  added: a swapped-in tile has no remembered size, and a flag far down moved
  the page 2929px (3/3; 0px without it).
- The rail (G15): html:has(.rail){scroll-padding-top} replaces .item's
  scroll-margin-top (the two add), so a control reached by Tab stops below
  the sticky rail too. Measured before: a Tab-focused flag button at 19.6px,
  under the rail's bottom at 47.6px. The scripts-off fallbacks are the old
  rules' numbers (132px, 217px at <=480), now pinned by a test. The height
  script follows the live rail after every in-place save (it watched the
  replaced node, and read 0px after one flag), and the rail's own controls
  cancel the padding (a Tab between stuck group links scrolled 357px).
- Reveal names (G17): no aria-label on any reveal control; the name is the
  words on it, the glyph in an aria-hidden span, the item's name as
  .sr-only text ("reveal a.png" / "hide a.png"). Reveal all drops
  aria-pressed (its words already say the state; r2b rules them) and its
  "on" look reads the .reveal-all class on <html>. No pixel changes.
- booth-dev's note: a refused batch's forms enter `unsent` with the
  refusal's words, and a later save says every standing failure's words
  (each once, in order) instead of "Saved.", and every warning says the
  other standing failures first, so no failure buries another. Test first:
  test_a_batch_refusal_outlives_an_unrelated_save.
- Rows re-anchored to the same failure: r2b "Space on a focused review
  button", r3 "C3 a held modifier" and both "C3 Space on a focused ..."
  (now in BoothKeys), r2c "the stage reveal shows with scripts off", and
  this contract's S3 doc-bar row and five S5b status-line rows.

Folded from the heid contract review (BEINKA, panel 4/4, thread
01M3NZJNX8D3BEYD48M9K3MV3Q): 24 flags, all prose the tests left open; the
contract states the tile/focus/cursor seam with S5b, the helper's union and
scope, the size's source and every path to none, Reveal all's name, the
refusal sentence's lifetime, and the fallback arithmetic (one test added).

Folded from the heid bug-hunt (HRÖSKVA, panel 4/4, thread
01M3P0ZPRSASFSE5K3PR4NTQP6): R1 closed docs and the cursor as an item, R2
the rail's height after a save, R3 no warning buries another, R5 the view?f=
link, R7 ctime in the size cache, R8 Escape from a player, R9 the rail's own
controls, R10 n on a closed doc. Refuted with reasons: R4 (unreachable: refused
picks re-send together), R6 (Chrome takes the same header's size with or
without the attributes; measured), R11 (by design).

From this slice's own falsifier runs: a "one row wide" row that mutated a
flex basis a non-wrapping bar just shrinks (re-aimed at the bar's flex), and
a Reveal-all "on look" read under the clicking pointer, where :hover draws
the same border (the pointer now leaves first; 3/3 proved).

Contract: as_antislop S5c.
Falsifiers: antislop.toml S5c section.
This commit is contained in:
vh
2026-09-29 01:09:15 -07:00
parent 62c0c9b638
commit 72d6c61629
15 changed files with 1730 additions and 136 deletions
+170
View File
@@ -631,3 +631,173 @@ def test_the_in_place_client_can_read_every_page(client):
p.feed(page)
assert not p.odd, (url, p.odd)
assert not p.nested, (url, p.nested)
# ---- S5c -----------------------------------------------------------------------------
def _figure(page, rel):
"""One tile's markup, by its data-item."""
m = re.search(r'<figure\b[^>]*data-item="' + re.escape(rel) + r'".*?</figure>', _markup(page), re.S)
assert m, f"no tile for {rel}"
return m.group(0)
def test_the_doc_summary_holds_no_controls(client):
"""G7: a <summary> is one button to a screen reader, and a <form> is not
valid inside one. The doc's summary holds its label; the controls sit in a
sibling toolbar."""
c, data = client
_s5_booth(data)
tile = _figure(c.get("/b/b/").text, "notes.md")
labels = [s for s in re.findall(r"<summary\b[^>]*>(.*?)</summary>", tile, re.S) if "doc-name" in s]
assert len(labels) == 1, labels
assert not re.search(r"<(a|button|form|input|textarea)\b", labels[0]), labels[0]
tools = re.search(r'<div class="doc-tools">(.*?)</div>\s*</div>', tile, re.S)
assert tools, "no doc toolbar"
t = tools.group(1)
assert 'href="view?f=notes.md"' in t and 'download' in t, t
assert 'class="blurtoggle' in t and 'class="flagtoggle' in t and "doc-close" in t, t
def _image(path, size, exif_orientation=None, fmt="PNG"):
from PIL import Image
im = Image.new("RGB", size, (90, 120, 160))
if exif_orientation is None:
im.save(path, fmt)
else:
exif = Image.Exif()
exif[0x0112] = exif_orientation
im.save(path, fmt, exif=exif)
def _img_size(tile):
img = re.search(r"<img\b[^>]*>", tile).group(0)
w, h = re.search(r'\bwidth="(\d+)"', img), re.search(r'\bheight="(\d+)"', img)
return (int(w.group(1)), int(h.group(1))) if w and h else None
def test_tile_images_carry_their_drawn_size(client):
"""G14: the picture's size as the browser draws it, so a lazy tile reserves
its box before it loads. Orientations 5-8 swap the numbers. Anything whose
header cannot be read safely gets no size, which is today's markup."""
pytest.importorskip("PIL.Image")
c, data = client
b = data / "g"
b.mkdir()
_image(b / "wide.png", (300, 200))
_image(b / "turned.jpg", (300, 200), exif_orientation=6, fmt="JPEG")
(b / "broken.png").write_bytes(b"\x89PNG\r\n\x1a\n")
(b / "pic.svg").write_text('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>')
(b / "link.png").symlink_to(b / "wide.png")
page = c.get("/b/g/").text
got = {r: _img_size(_figure(page, r)) for r in ("wide.png", "turned.jpg", "broken.png", "pic.svg", "link.png")}
assert got == {"wide.png": (300, 200), "turned.jpg": (200, 300), "broken.png": None,
"pic.svg": None, "link.png": None}, got
def test_reading_a_size_decodes_nothing(tmp_path, monkeypatch):
"""G14: the header only. Pillow's PNG getexif() decodes the whole picture to
look for a late eXIf chunk; a gallery render must not."""
Image = pytest.importorskip("PIL.Image")
from PIL import ImageFile
from booth import thumbs
_image(tmp_path / "a.png", (300, 200))
_image(tmp_path / "b.jpg", (300, 200), exif_orientation=6, fmt="JPEG")
def boom(self, *a, **k):
raise AssertionError("decoded")
monkeypatch.setattr(Image.Image, "load", boom)
monkeypatch.setattr(ImageFile.ImageFile, "load", boom)
thumbs._read_size.cache_clear()
assert thumbs.drawn_size(tmp_path / "a.png") == (300, 200)
assert thumbs.drawn_size(tmp_path / "b.jpg") == (200, 300)
_REVEAL = re.compile(r'<button\b(?=[^>]*(?:class="[^"]*\breveal(?:-all-btn)?\b|data-reveal-all))([^>]*)>(.*?)</button>', re.S)
def test_reveal_glyphs_are_hidden_from_the_name(client):
"""G17: a reveal control's name is the words on it. No aria-label to
contradict the words after a flip, no aria-pressed on a control whose words
already say its state, and no glyph read aloud."""
from booth.app import set_blurred
c, data = client
b = _s5_booth(data)
for rel in ("a.png", "c.png", "notes.md"):
set_blurred(b, rel, True)
seen = 0
for url in ("/b/b/", "/b/b/view?f=a.png", "/b/b/compare?a=a.png&b=c.png", "/b/b/view?f=notes.md"):
for attrs, inner in _REVEAL.findall(_markup(c.get(url).text)):
seen += 1
assert "aria-label" not in attrs and "aria-pressed" not in attrs, (url, attrs)
bare = re.sub(r'<span\b[^>]*aria-hidden="true"[^>]*>.*?</span>', "", inner, flags=re.S)
assert not re.search("[\U0001F300-\U0001FAFF]", bare), (url, inner)
assert seen >= 7, seen # tile x3 (two images, the doc), review x2, compare x3, doc page x2
def test_a_planted_fifo_or_link_costs_its_size_and_never_hangs(tmp_path):
"""G14: opened O_NONBLOCK, a FIFO with no writer reads as empty instead of
holding the render forever; opened O_NOFOLLOW, a link is refused."""
import threading
pytest.importorskip("PIL.Image")
from booth.thumbs import drawn_size
_image(tmp_path / "a.png", (300, 200))
os.mkfifo(tmp_path / "pipe.png")
(tmp_path / "link.png").symlink_to(tmp_path / "a.png")
got = {}
t = threading.Thread(target=lambda: got.update(pipe=drawn_size(tmp_path / "pipe.png")), daemon=True)
t.start()
t.join(3)
assert not t.is_alive() and got == {"pipe": None}, got
assert drawn_size(tmp_path / "link.png") is None
assert drawn_size(tmp_path / "a.png") == (300, 200) # the positive control
def test_a_size_is_read_once_per_version_of_the_file(tmp_path, monkeypatch):
"""G14: cached by the file's identity, so a gallery render reads each
header once while it is unchanged, and a replaced file is read again."""
Image = pytest.importorskip("PIL.Image")
from booth import thumbs
_image(tmp_path / "a.png", (300, 200))
thumbs._read_size.cache_clear()
opened = []
real = Image.open
monkeypatch.setattr(thumbs._Image, "open", lambda *a, **k: (opened.append(1), real(*a, **k))[1])
first = [thumbs.drawn_size(tmp_path / "a.png") for _ in range(3)]
_image(tmp_path / "a.png", (120, 480))
os.utime(tmp_path / "a.png", ns=(1, 1))
again = thumbs.drawn_size(tmp_path / "a.png")
assert first == [(300, 200)] * 3 and again == (120, 480), (first, again)
assert len(opened) == 2, opened
def test_a_file_replaced_in_place_keeps_no_stale_size(tmp_path):
"""HRÖSKVA R7: `cp -p` over a file keeps its inode and restores its mtime,
and a same-length replacement kept its size too, so the cache served the
old picture's size. The change time moves on every write."""
pytest.importorskip("PIL.Image")
from PIL import PngImagePlugin
from booth import thumbs
def png(size, pad):
info = PngImagePlugin.PngInfo()
info.add_text("pad", "x" * pad)
from PIL import Image
import io
buf = io.BytesIO()
Image.new("RGB", size, (90, 120, 160)).save(buf, "PNG", pnginfo=info)
return buf.getvalue()
a, b = png((300, 200), 0), png((120, 480), 0)
a, b = (png((300, 200), max(0, len(b) - len(a))), png((120, 480), max(0, len(a) - len(b))))
assert len(a) == len(b), (len(a), len(b))
f = tmp_path / "a.png"
f.write_bytes(a)
st = os.stat(f)
thumbs._read_size.cache_clear()
first = thumbs.drawn_size(f)
time.sleep(0.01)
with open(f, "r+b") as fh: # in place: same inode, same length
fh.write(b)
os.utime(f, ns=(st.st_atime_ns, st.st_mtime_ns))
assert os.stat(f).st_ino == st.st_ino and os.stat(f).st_size == st.st_size
assert (first, thumbs.drawn_size(f)) == ((300, 200), (120, 480))