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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user