fix(as-S5a): every control named, one h1 and a skip link, rings and hit areas

The markup and CSS half of the anti-slop interaction work. The in-place
client is untouched (that is S5b).
- Glyph-only controls carry a name: the withdraw ×s, downloads, open full
  page, the viewers' ✕, the board's pin, copy and remove, the bench's
  remove, the 1:1 toggle ("1:1, natural pixels"). Film-strip and tray
  frames carry the file's name as sr-only text instead of reading "01".
  A Desk row's wipe names its booth.
- Fields are named by aria-label, not by their placeholder.
- The inline ask's options are a radiogroup labelled by the prompt; a
  single-question fieldset gets an sr-only legend; a titled ask's title
  takes bk-ask-<id>-title (it duplicated the question's id).
- One h1 per page (sr-only on the Desk, review and compare), a skip link
  to <main id="main">, theme-color for light and dark.
- The review tape is one picture (role=img); its segments leave the tab
  order (the film strip holds the same links, named).
- Wipe now uses the Desk's delegated prompt, moved to base.html: it names
  the booth and asks the kept-booth question for a kept booth.
- Embed focus rings of its own; rings drawn inside clipping containers;
  the withdraw × at least 24px, 44px under a coarse pointer;
  touch-action:manipulation; strips contain their overscroll; a long
  slug wraps on a phone.
- A truncated why carries its full text in title; a countdown of 48h or
  more reads in days.
Two r2_flow.toml rows for the confirm helper now name base.html, where
the helper moved (anchors unchanged; the gate found them drifted).

Contract: as_antislop S5a. Falsifiers: antislop.toml 86/86 proved (S1-S6, S5a);
all 12 tables 366/366 proved on this tree.
This commit is contained in:
vh
2026-09-28 13:30:31 -07:00
parent ec807fe41b
commit d4f64fd7ec
16 changed files with 675 additions and 82 deletions
+268
View File
@@ -375,3 +375,271 @@ file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_the_hazard_stripe_is_a_pseudo_element"
old = """ .wipe-lg button::before,.board-del-sel:not(:disabled)::before{content:"";position:absolute;"""
new = """ .wipe-lg button::after,.board-del-sel:not(:disabled)::after{content:"";position:absolute;"""
# ---- S5a: accessibility plumbing (names, landmarks, focus, hit areas)
[[mutation]]
label = "S5a the withdraw-note × unnamed again"
file = "booth/templates/_marks.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """<button type="submit" class="mark-x" title="withdraw this note" aria-label="withdraw this note">×</button>"""
new = """<button type="submit" class="mark-x" title="withdraw this note">×</button>"""
[[mutation]]
label = "S5a a film frame named only by its number"
file = "booth/templates/view.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """ <span class="sr-only">{{ x.name }}</span>{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">"""
new = """ {%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">"""
[[mutation]]
label = "S5a a flagged-tray item named only by its number"
file = "booth/templates/_marks.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """<span class="sr-only">{{ it.name }}</span>{%- if it.kind"""
new = """{%- if it.kind"""
[[mutation]]
label = "S5a the 1:1 button read as digits"
file = "booth/templates/view.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """ aria-label="1:1, natural pixels" title="natural pixels — drag to pan a large picture">"""
new = """ title="natural pixels — drag to pan a large picture">"""
[[mutation]]
label = "S5a the doc's open-full-page glyph unnamed"
file = "booth/templates/booth.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """ title="open full page" aria-label="open {{ it.name }} full page">⤢</a>"""
new = """ title="open full page">⤢</a>"""
[[mutation]]
label = "S5a compare's close glyph unnamed"
file = "booth/templates/compare.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """ title="back to the review of A (Esc)" aria-label="back to the review of A">✕</a>"""
new = """ title="back to the review of A (Esc)">✕</a>"""
[[mutation]]
label = "S5a the board's pin star unnamed"
file = "booth/templates/booth.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """ aria-pressed="{{ 'true' if e.pinned else 'false' }}" aria-label="pin {{ e.desc }}""""
new = """ aria-pressed="{{ 'true' if e.pinned else 'false' }}""""
[[mutation]]
label = "S5a the board's remove × unnamed"
file = "booth/templates/booth.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """ title="remove this link" aria-label="remove the link {{ e.desc }}""""
new = """ title="remove this link""""
[[mutation]]
label = "S5a a Desk wipe that does not say which booth"
file = "booth/templates/index.html"
test = "tests/test_antislop.py::test_desk_row_controls_name_their_booth"
old = """aria-label="wipe the booth {{ b.name }}""""
new = """aria-label="wipe booth""""
[[mutation]]
label = "S5a the note field named only by its placeholder"
file = "booth/templates/_marks.html"
test = "tests/test_antislop.py::test_fields_are_named"
old = """<textarea name="text" rows="2" aria-label="a note on this booth, for the session that posted it" placeholder"""
new = """<textarea name="text" rows="2" placeholder"""
[[mutation]]
label = "S5a the bench URL field unnamed"
file = "booth/templates/booth.html"
test = "tests/test_antislop.py::test_fields_are_named"
old = """<input type="url" name="url" aria-label="https://host:port/" placeholder"""
new = """<input type="url" name="url" placeholder"""
[[mutation]]
label = "S5a the embed's notes field unnamed"
file = "booth/templates/_ask_inline.html"
test = "tests/test_antislop.py::test_fields_are_named"
old = """ aria-label="{{ a.notes_label }}" placeholder="{{ a.notes_label }} (optional)">"""
new = """ placeholder="{{ a.notes_label }} (optional)">"""
[[mutation]]
label = "S5a the inline ask's options not a named group"
file = "booth/templates/_ask_inline.html"
test = "tests/test_antislop.py::test_radio_groups_are_named_and_ids_are_unique"
old = """<div class="bk-ask-opts" role="radiogroup" aria-labelledby="""
new = """<div class="bk-ask-opts" aria-labelledby="""
[[mutation]]
label = "S5a the group names a prompt id that is not there"
file = "booth/templates/_ask_inline.html"
test = "tests/test_antislop.py::test_radio_groups_are_named_and_ids_are_unique"
old = """<p class="bk-ask-prompt" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}-prompt">"""
new = """<p class="bk-ask-prompt">"""
[[mutation]]
label = "S5a a titled ask's title reuses the question's id"
file = "booth/templates/_ask_inline.html"
test = "tests/test_antislop.py::test_radio_groups_are_named_and_ids_are_unique"
old = """<p class="bk-ask-title" id="bk-ask-{{ a.id }}-title">"""
new = """<p class="bk-ask-title" id="bk-ask-{{ a.id }}">"""
[[mutation]]
label = "S5a a single-question fieldset without a legend"
file = "booth/templates/_marks.html"
test = "tests/test_antislop.py::test_radio_groups_are_named_and_ids_are_unique"
old = """{% else %}<legend class="sr-only">{{ q.prompt }}</legend>{% endif %}"""
new = """{% endif %}"""
[[mutation]]
label = "S5a the Desk without an h1"
file = "booth/templates/index.html"
test = "tests/test_antislop.py::test_every_page_has_one_h1_and_a_skip_link"
old = """<h1 class="sr-only">Desk</h1>
"""
new = """"""
[[mutation]]
label = "S5a the review without an h1"
file = "booth/templates/view.html"
test = "tests/test_antislop.py::test_every_page_has_one_h1_and_a_skip_link"
old = """<h1 class="sr-only">{{ file }}</h1>
"""
new = """"""
[[mutation]]
label = "S5a compare without an h1"
file = "booth/templates/compare.html"
test = "tests/test_antislop.py::test_every_page_has_one_h1_and_a_skip_link"
old = """<h1 class="sr-only">compare {{ sides.a.rel }} and {{ sides.b.rel }}</h1>
"""
new = """"""
[[mutation]]
label = "S5a no skip link"
file = "booth/templates/base.html"
test = "tests/test_antislop.py::test_every_page_has_one_h1_and_a_skip_link"
old = """<a class="skip-link" href="#main">Skip to content</a>
"""
new = """"""
[[mutation]]
label = "S5a the skip link points at nothing"
file = "booth/templates/base.html"
test = "tests/test_antislop.py::test_every_page_has_one_h1_and_a_skip_link"
old = """<main id="main">"""
new = """<main>"""
[[mutation]]
label = "S5a no dark theme-color"
file = "booth/templates/base.html"
test = "tests/test_antislop.py::test_theme_color_for_both_schemes"
old = """<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#15191d">
"""
new = """"""
[[mutation]]
label = "S5a the tape is a list of links again"
file = "booth/templates/view.html"
test = "tests/test_antislop.py::test_the_tape_is_one_picture"
old = """<div class="tape" data-region="tape" role="img" aria-label="""
new = """<div class="tape" data-region="tape" aria-label="""
[[mutation]]
label = "S5a the tape's segments back in the tab order"
file = "booth/templates/view.html"
test = "tests/test_antislop.py::test_the_tape_is_one_picture"
old = """ title="{{ num(x.ordinal) }} {{ x.name }}" tabindex="-1" aria-hidden="true"></a>"""
new = """ title="{{ num(x.ordinal) }} {{ x.name }}"></a>"""
[[mutation]]
label = "S5a Wipe now back on an inline anonymous prompt"
file = "booth/templates/booth.html"
test = "tests/test_antislop.py::test_wipe_now_asks_by_name"
old = """ data-booth="{{ name }}" data-confirm="{{ 'wipe-kept' if kept else 'wipe' }}">"""
new = """ onsubmit="return confirm('Wipe this booth now?')">"""
[[mutation]]
label = "S5a a kept booth's Wipe now asks the ordinary question"
file = "booth/templates/booth.html"
test = "tests/test_antislop_browser.py::test_wipe_now_asks_by_name_in_the_browser"
old = """data-confirm="{{ 'wipe-kept' if kept else 'wipe' }}""""
new = """data-confirm="wipe""""
[[mutation]]
label = "S5a the shared prompt does not reach a booth page"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_wipe_now_asks_by_name_in_the_browser"
old = """ var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;"""
new = """ var form = ev.target.closest ? ev.target.closest('.desk form[data-confirm]') : null;"""
[[mutation]]
label = "S5a hours never roll up to days"
file = "booth/app.py"
test = "tests/test_antislop.py::test_human_dur_rolls_up_to_days"
old = """ if h >= 48:"""
new = """ if h >= 4800:"""
[[mutation]]
label = "S5a the embed's submit button has no ring of its own"
file = "booth/static/embed.js"
test = "tests/test_antislop.py::test_embed_chrome_draws_its_own_focus_rings"
old = """ ".bk-ask-go:focus-visible{outline:2px solid var(--bk-accent);outline-offset:2px}",
"""
new = """"""
[[mutation]]
label = "S5a an embed option has no ring"
file = "booth/static/embed.js"
test = "tests/test_antislop.py::test_embed_chrome_draws_its_own_focus_rings"
old = """ ".bk-ask-opt:has(input:focus-visible){outline:2px solid var(--bk-accent);outline-offset:1px}",
"""
new = """"""
[[mutation]]
label = "S5a rings drawn outside a clipping container"
file = "booth/templates/base.html"
test = "tests/test_antislop.py::test_focus_rings_are_drawn_inside_clipping_containers"
old = """ .desk-panel a:focus-visible{outline-offset:-2px}"""
new = """ .desk-panel a:focus-visible{outline-offset:2px}"""
[[mutation]]
label = "S5a a truncated why without its full text"
file = "booth/templates/_provenance.html"
test = "tests/test_antislop.py::test_a_truncated_why_carries_its_full_text"
old = """<span class="prov-why" title="{{ m.why }}">"""
new = """<span class="prov-why">"""
[[mutation]]
label = "S5a the withdraw × at its old size on a desktop"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_withdraw_buttons_are_big_enough_to_hit"
old = """ .mark-x{min-width:24px;min-height:24px}"""
new = """ .mark-x{}"""
[[mutation]]
label = "S5a the withdraw × not enlarged under a finger"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_withdraw_buttons_are_big_enough_to_hit"
old = """ @media (pointer:coarse){.mark-x,.flagtoggle button"""
new = """ @media (pointer:coarse){.flagtoggle button"""
[[mutation]]
label = "S5a double-tap zoom delay back on controls"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_touch_and_scroll_behaviour"
old = """ a,button,summary,label,input,select,textarea{touch-action:manipulation}"""
new = """ summary,label,input,select,textarea{touch-action:manipulation}"""
[[mutation]]
label = "S5a the film strip hands its scroll to the page"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_touch_and_scroll_behaviour"
old = """ .film,.vcap,.doc-body,.cmp-strip{overscroll-behavior:contain}"""
new = """ .vcap,.doc-body,.cmp-strip{overscroll-behavior:contain}"""
[[mutation]]
label = "S5a a long slug runs off a phone"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_touch_and_scroll_behaviour"
old = """ @media (max-width:600px){.h1-slug{white-space:normal;overflow-wrap:anywhere}}"""
new = """ @media (max-width:600px){.h1-slug{}}"""
+2 -2
View File
@@ -187,7 +187,7 @@ new = '''
[[mutation]]
label = "the wipe dialog shows the agent-made name raw (bidi, newline)"
file = "booth/templates/index.html"
file = "booth/templates/base.html" # as S5a: the confirm helper moved here from index.html
test = "tests/test_flow_browser.py::test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open"
old = '''
if (!confirm(word(shown(form.getAttribute('data-booth') || '')))) ev.preventDefault();'''
@@ -196,7 +196,7 @@ new = '''
[[mutation]]
label = "an unknown data-confirm word submits with no prompt (fail open)"
file = "booth/templates/index.html"
file = "booth/templates/base.html" # as S5a: the confirm helper moved here from index.html
test = "tests/test_flow_browser.py::test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open"
old = '''
var word = WORDS[form.getAttribute('data-confirm')] || ASK;
+191
View File
@@ -306,3 +306,194 @@ def test_needs_you_rows_carry_no_side_stripe():
def test_the_brand_dot_is_matte():
decl = _rule((_T / "base.html").read_text(), ".brand .dot")
assert "box-shadow" not in decl, decl
# ---- S5a: accessibility plumbing (markup and CSS) --------------------------------
# Written after the code: each test is proved by its row in antislop.toml, which
# runs the change it forbids and must see it fail.
import html as _html
def _s5_booth(data):
"""A booth that renders every control the S5a claims cover: a doc, a file, an
image with a note and a flag, a titled single-question ask, and a board."""
from booth.marks import set_flag
b = data / "b"
b.mkdir()
(b / "notes.md").write_text("# Notes\n\ntext\n")
(b / "kit.zip").write_bytes(b"PK\x05\x06" + b"\x00" * 18)
(b / "a.png").write_bytes(_PNG)
(b / "c.png").write_bytes(_PNG)
set_flag(b, "a.png", True)
write_note(b, "a.png", "soft edges")
write_note(b, None, "about the booth")
declare_pick(b, "p1", {"title": "Round one", "prompt": "Which?", "options": ["North", "South"]})
_s5_board(data)
return b
def _s5_board(data):
"""The link board, with a row and a bench: its controls are named too."""
from booth.benches import upsert_bench
from booth.links import LINKS_FILE
(data / "links").mkdir()
(data / "links" / LINKS_FILE).write_text("- [Booth](http://x.example/) <sub>· infra-ops · 2026-09-06 23:35</sub>\n")
upsert_bench(data, "http://10.100.10.50:5173/", "peedlar desk", "peedlar-dev")
_PNG = (b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x03\x01\x01\x00\xc9\xfe\x92\xef\x00\x00\x00\x00IEND\xaeB`\x82")
def _pages(c):
return {u: c.get(u).text for u in ("/", "/b/b/", "/b/b/view?f=a.png", "/b/b/compare?a=a.png&b=c.png",
"/b/b/marks", "/b/b/view?f=notes.md", "/b/links/")}
def _markup(page):
"""The page's own markup: script and style bodies are strings and comments
are prose (the stylesheet's comments name `<textarea>`), not elements."""
page = re.sub(r"<(script|style)\b.*?</\1>", " ", page, flags=re.S)
return re.sub(r"<!--.*?-->", " ", page, flags=re.S)
def _name(attrs, inner):
"""The accessible name, as far as markup decides it: aria-label wins, else
the text, counting each image's alt (an <img alt="a.png"> names its link)."""
m = re.search(r'aria-label="([^"]*)"', attrs)
if m:
return _html.unescape(m.group(1))
inner = re.sub(r'<img\b[^>]*\balt="([^"]*)"[^>]*>', r" \1 ", inner)
return _html.unescape(_text(inner))
def _controls(page):
for m in re.finditer(r"<(a|button)\b([^>]*)>(.*?)</\1>", _markup(page), flags=re.S):
if 'aria-hidden="true"' in m.group(2):
continue # a deliberate duplicate (the Desk's thumbnail link) is not a control
yield m.group(2), _name(m.group(2), m.group(3))
def test_every_control_has_a_word_for_a_name(client):
"""A control a screen reader announces as "×", "↗" or "01" has no name. Every
link and button's name, as the markup decides it, carries a word."""
c, data = client
_s5_booth(data)
for url, page in _pages(c).items():
seen = 0
for attrs, name in _controls(page):
seen += 1
assert re.search(r"[A-Za-z]", name), f"{url}: a control named {name!r}: {attrs[:120]}"
assert seen, f"{url}: no control found (positive control)"
def test_desk_row_controls_name_their_booth(client):
c, data = client
for n in ("alpha", "beta"):
(data / n).mkdir()
(data / n / "x.txt").write_text("x")
labels = re.findall(r'aria-label="(wipe the (?:kept )?booth [^"]+)"', c.get("/").text)
assert len(labels) == 2 and len(set(labels)) == 2 and any("alpha" in s for s in labels), labels
def test_fields_are_named(client):
c, data = client
_s5_booth(data)
pages = _pages(c)
pages["embed"] = " ".join(m["whole"] + m["submit"] for m in c.get("/b/b/embed.json").json()["marks"])
for url, page in pages.items():
for m in re.finditer(r"<(textarea|input)\b([^>]*)>", _markup(page), flags=re.S):
attrs = m.group(2)
if re.search(r'type="(hidden|radio|checkbox|submit)"', attrs):
continue
ok = "aria-label=" in attrs or re.search(r'id="([^"]+)"', attrs) and \
re.search(r'<label[^>]*for="%s"' % re.escape(re.search(r'id="([^"]+)"', attrs).group(1)), page)
assert ok, f"{url}: an unnamed field: {attrs[:120]}"
def test_radio_groups_are_named_and_ids_are_unique(client):
c, data = client
_s5_booth(data)
(m,) = c.get("/b/b/embed.json").json()["marks"]
# The embed places an ask one of two ways: `whole` (title, questions and
# submit in one piece), or its questions one by one plus `submit`. Never both.
placements = {"whole": m["whole"], "parts": "".join(q["html"] for q in m["questions"]) + m["submit"]}
for how, frag in placements.items():
groups = re.findall(r'role="radiogroup" aria-labelledby="([^"]+)"', frag)
assert groups and all(f'id="{g}"' in frag for g in groups), (how, groups)
# the ASK ids are this slice's (a titled single ask emitted `bk-ask-<id>`
# twice). Mark ids (`mark-note-1`) also repeat across the tile and the
# aside; the CLI prints `#mark-<id>` links to them, so that one is
# reported, not changed here.
ids = re.findall(r'\bid="(bk-ask-[^"]+)"', frag)
dupes = {i for i in ids if ids.count(i) > 1}
assert ids and not dupes, (how, f"duplicate ask ids {sorted(dupes)[:5]}")
marks = c.get("/b/b/marks").text
for fs in re.findall(r"<fieldset\b.*?</fieldset>", marks, flags=re.S):
if 'type="radio"' in fs:
assert "<legend" in fs, fs[:160]
def test_every_page_has_one_h1_and_a_skip_link(client):
c, data = client
_s5_booth(data)
for url in ("/", "/b/b/", "/b/b/view?f=a.png", "/b/b/compare?a=a.png&b=c.png", "/b/b/marks"):
page = re.sub(r'<article class="markdown-body.*?</article>', " ", c.get(url).text, flags=re.S) # a doc's own h1 is content
assert len(re.findall(r"<h1\b", page)) == 1, (url, len(re.findall(r"<h1\b", page)))
assert re.search(r'<a class="skip-link" href="#main">', page) and 'id="main"' in page, url
def test_theme_color_for_both_schemes(client):
c, _ = client
page = c.get("/").text
for scheme in ("light", "dark"):
assert re.search(r'<meta name="theme-color" media="\(prefers-color-scheme: %s\)" content="#[0-9a-f]{6}">' % scheme, page), scheme
def test_the_tape_is_one_picture(client):
c, data = client
_s5_booth(data)
page = c.get("/b/b/view?f=a.png").text
assert re.search(r'<div class="tape" data-region="tape" role="img" aria-label="', page)
segs = re.findall(r'<a class="tape-s[^>]*>', page, flags=re.S)
assert segs and all('tabindex="-1"' in s and 'aria-hidden="true"' in s for s in segs), segs[:2]
def test_wipe_now_asks_by_name(client):
c, data = client
_s5_booth(data)
page = c.get("/b/b/").text
form = re.search(r'<form class="wipe wipe-lg"[^>]*>', page, flags=re.S).group(0)
assert 'data-booth="b"' in form and 'data-confirm="wipe"' in form and "onsubmit" not in form, form
assert "var WORDS = {" in page, "the shared confirm helper is on the booth page"
def test_human_dur_rolls_up_to_days():
from booth.app import human_dur
assert human_dur(47 * 3600) == "47h"
assert human_dur(48 * 3600) == "2d"
assert human_dur(167 * 3600 + 12 * 60) == "6d 23h"
def test_embed_chrome_draws_its_own_focus_rings():
js = _EMBED.read_text()
assert re.search(r'"\.booth-nav-home:focus-visible,\.booth-nav-asks:focus-visible\{outline:2px solid', js)
assert re.search(r'"\.bk-ask-go:focus-visible\{outline:2px solid', js)
assert re.search(r'"\.bk-ask-opt:has\(input:focus-visible\)\{outline:2px solid', js)
def test_focus_rings_are_drawn_inside_clipping_containers():
css = (_T / "base.html").read_text()
m = re.search(r"\.theme button:focus-visible,\.vtoggle button:focus-visible,\.item a:focus-visible,\s*\.desk-panel a:focus-visible\{outline-offset:-2px\}", css)
assert m
def test_a_truncated_why_carries_its_full_text(client):
c, data = client
b = data / "w"
b.mkdir()
(b / "x.txt").write_text("x")
(b / ".booth.json").write_text('{"handle": "design-dev", "why": "a long reason that the Desk truncates with an ellipsis"}')
page = c.get("/").text
assert re.search(r'<span class="prov-why" title="a long reason that the Desk truncates with an ellipsis">', page)
+59
View File
@@ -172,3 +172,62 @@ def test_the_hazard_stripe_is_a_pseudo_element(browser, live):
assert got["own"] == "none", f"the button paints its own stripe: {got['own'][:80]}"
assert "repeating-linear-gradient" in got["pre"] and got["h"] == "3px" and got["content"] != "none", got
page.close()
# ---- S5a: hit areas, and Wipe now asks by name -------------------------------------
def test_withdraw_buttons_are_big_enough_to_hit(browser, live):
from booth.marks import write_note
base, root = live
g = root / "g"
g.mkdir()
(g / "a.png").write_bytes(_png(64, 48))
write_note(g, "a.png", "soft")
for ctx_args, floor in (({"viewport": {"width": 1280, "height": 800}}, 24),
({"viewport": PHONE, "is_mobile": True, "has_touch": True}, 44)):
ctx = browser.new_context(**ctx_args)
page = ctx.new_page()
page.goto(f"{base}/b/g/marks", wait_until="load") # visible at both widths
box = page.locator(".mark-x").first.evaluate("e => e.getBoundingClientRect().height")
assert box >= floor, (ctx_args, box)
ctx.close()
def test_wipe_now_asks_by_name_in_the_browser(browser, live):
base, root = live
for name, kept in (("alpha", False), ("beta", True)):
(root / name).mkdir()
(root / name / "x.txt").write_text("x")
if kept:
(root / name / ".forever").write_text("")
page = browser.new_page(viewport={"width": 1280, "height": 800})
page.goto(f"{base}/b/{name}/", wait_until="load")
said = []
page.on("dialog", lambda d: (said.append(d.message), d.dismiss()))
page.locator(".wipe-lg button").click()
page.wait_for_timeout(300)
assert said and f"“{name}”" in said[0], said
assert ("KEPT" in said[0]) == kept, said[0]
assert (root / name).exists(), "dismissing the prompt must not wipe"
page.close()
def test_touch_and_scroll_behaviour(browser, live):
"""No double-tap zoom delay on a control; the film strip does not hand its
scroll to the page at its end; a long booth slug wraps on a phone instead of
running off the edge (measured on COMPUTED style, not the stylesheet)."""
base, root = live
g = root / "a-very-long-booth-name-that-runs-off-a-phone-screen-2026-09-28"
g.mkdir()
(g / "a.png").write_bytes(_png(64, 48))
(g / "b.png").write_bytes(_png(64, 48))
(g / ".booth.json").write_text('{"handle": "design-dev", "title": "Review"}') # the slug shows beside a title
page = browser.new_page(viewport=PHONE)
page.goto(f"{base}/b/{g.name}/view?f=a.png", wait_until="load")
assert page.locator(".vbar a.vx").evaluate("e => getComputedStyle(e).touchAction") == "manipulation"
assert page.locator(".film").evaluate("e => getComputedStyle(e).overscrollBehaviorX") == "contain"
page.goto(f"{base}/b/{g.name}/", wait_until="load")
slug = page.locator(".h1-slug").evaluate(
"e => ({ws: getComputedStyle(e).whiteSpace, r: e.getBoundingClientRect().right, vw: innerWidth})")
assert slug["ws"] == "normal" and slug["r"] <= slug["vw"], slug
page.close()