T9.5 - C1+S8: the help-tip is real, the audit is written, BL-001 is dead

S8, finished where the plan said it would be: every .help-tip badge is a
<button> - upgraded by the component itself at load (help.js), with
helpTipUpgrade() for late renders, so a badge added tomorrow is born
reachable. The count the task warned about came true: 15 at wave 0, 18 at the
wave 6 exit, 20 at the start of this task - all 20 buttons now, and the fix
being in the component is what stops the number growing again. One
viewport-clamped role=tooltip bubble serves every badge: focus shows it,
Escape hides it, tap toggles it, tap-elsewhere closes it - the touch path
Field View's tablets never had. The injected styles now use theme tokens
(four raw hexes of the S5 kind, gone).

BL-001, CLOSED after three causes and nine waves: the old CSS ::after escaped
its badge to the right and was the creator's last 390px overflow. The clamped
bubble ends it - scrollWidth 390 vs clientWidth 390 - and frame_check's pin
FLIPPED, exactly as designed: it asserted the failure until the fix landed,
and now asserts the fix so a regression reopens the entry loudly.

The audit (docs/reference/accessibility-audit.md), every number probe-backed:
- div/span click handlers: 12/2 at wave 0 -> 0 (the wizard's constraint
  library entries and the dashboard chips became buttons here; the comments
  backdrop stopped pretending to be a control)
- outline:none without replacement: 0 (wp-chrome's one is the documented S12
  exception - its ring is on :focus-within, one ring not two)
- aria-live: every toast system and banner announces
- native dialogs: 79 -> 21, all on surfaces no S1 task named (admin, users,
  launcher) - documented as BL-024 with the T7.9 kit ready for them
- keyboard-only primary flow: covered leg by leg by the probes that dispatch
  real CDP key events, cited in the document

Three stale count-pins re-pointed to the numbers this task reached (stepper's
baseline-minus-10, form_structure's one-span-left, frame_check's BL-001 pin) -
each now pins the TARGET so slack cannot hide a regression.

Verification (each probe run alone): NEW tests/helptip_check.py 13/13.
Regressions: a11y_check 22/22, stepper_check 71/71, form_structure_check
50/51 (BL-022's product question), pipeline_check 44/44, frame_check 38/38.

Items: C1, S8 (BL-001 closed, BL-024 opened)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 13:37:10 -07:00
parent b83f2fd8d5
commit 0dcea8d725
13 changed files with 436 additions and 28 deletions

189
tests/helptip_check.py Normal file
View File

@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""Is every help-tip reachable by keyboard and by touch? — C1 + S8, T9.5.
The badges were <span> elements whose :focus CSS was dead code (no tabindex)
and whose touch path did not exist - on tablets, Field View's surface. Now the
component upgrades every badge to a button at load, and one viewport-clamped
role=tooltip bubble serves them all. This probe drives a badge with REAL key
events and a tap at 390px, then greps the app-wide metrics the audit document
cites.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import io
import json
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from sections_check import set_sop # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def settle(seconds=0.5):
time.sleep(seconds)
def strip_js(src):
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
return "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
def wait_creator(page, tries=40):
for _ in range(tries):
if page.eval("!!window.wpCreatorReady"):
return True
time.sleep(0.3)
return False
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
# ── 1. the greps the audit cites ──────────────────────────────────────────
print("\n1. the audit's grep metrics")
divspan = 0
outline_bad = []
for name in sorted(os.listdir(HTML)):
if not name.endswith((".html", ".js", ".css")):
continue
src = io.open(os.path.join(HTML, name), encoding="utf-8").read()
code = strip_js(src) if not name.endswith(".css") else re.sub(r"/\*.*?\*/", "", src, flags=re.S)
divspan += len(re.findall(r"<(div|span)[^>]*\bonclick=", code))
for m in re.finditer(r"outline:\s*none", code):
# The replacement can sit in the rule ABOVE (wp-chrome's search shell
# rings on :focus-within, and ringing input + shell would draw two),
# so the window looks both ways.
ctx = code[max(0, m.start() - 400):m.start() + 260]
tail = ctx[ctx.find("outline:") + 12:]
if ("outline" not in tail and "box-shadow" not in ctx
and "focus-within" not in ctx and "border" not in tail):
outline_bad.append(name)
chk("div/span click handlers app-wide: 0 (baseline 12/2)", divspan == 0, divspan)
chk("outline:none without a replacement: 0", not outline_bad, outline_bad[:4])
chk("the audit document exists with the per-page table",
"## Per-page results" in io.open(os.path.join(ROOT, "docs", "reference",
"accessibility-audit.md"), encoding="utf-8").read())
tmpdir = tempfile.mkdtemp(prefix="wpsuite-tip-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
set_sop(db_path, {})
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(390, 844, mobile=True)
page.goto(base + "/wp-creation-index.html?project=projA")
dismiss_dialogs(page)
chk("the creator boots at 390px", wait_creator(page))
settle(1.8)
# ── 2. every badge is a real button ──────────────────────────────────
print("\n2. the component, upgraded")
counts = json.loads(page.eval("""JSON.stringify({
total: document.querySelectorAll('.help-tip').length,
buttons: document.querySelectorAll('button.help-tip').length,
spans: document.querySelectorAll('span.help-tip').length,
})"""))
chk("every help-tip on the page is a <button>; zero spans remain",
counts["total"] > 0 and counts["spans"] == 0
and counts["buttons"] == counts["total"], ascii_(counts))
chk("...with an accessible name and a declared state",
page.eval("""[...document.querySelectorAll('button.help-tip')]
.every(b => b.getAttribute('aria-label') && b.getAttribute('aria-expanded') !== null)"""))
# ── 3. keyboard ───────────────────────────────────────────────────────
print("\n3. keyboard")
# Programmatic focus() fires no focusin unless the document HAS focus -
# the exact trap form_structure_check documents. Emulate it, loudly.
page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True})
chk("focus emulation is on, so a focus reading means something",
page.eval("document.hasFocus()") is True)
page.eval("""(() => {
const b = [...document.querySelectorAll('button.help-tip')]
.find(x => x.offsetParent !== null) || document.querySelector('button.help-tip');
b.scrollIntoView({block:'center'}); b.focus();
})()""")
settle(0.4)
chk("focusing a badge shows the tooltip",
page.eval("!!(document.getElementById('wp-tip-bubble') && !document.getElementById('wp-tip-bubble').hidden)")
and page.eval("(document.getElementById('wp-tip-bubble')||{}).textContent.length > 0"))
chk("...as a role=tooltip the badge points at",
page.eval("document.getElementById('wp-tip-bubble').getAttribute('role')") == "tooltip"
and page.eval("document.activeElement.getAttribute('aria-describedby')") == "wp-tip-bubble")
bubble = json.loads(page.eval("""JSON.stringify((() => {
const r = document.getElementById('wp-tip-bubble').getBoundingClientRect();
return {left: r.left, right: r.right};
})())"""))
chk("390px: the bubble is CLAMPED to the viewport (BL-001's cause, dead)",
bubble["left"] >= 0 and bubble["right"] <= 390, ascii_(bubble))
# ── 4. touch ──────────────────────────────────────────────────────────
print("\n4. touch")
page.eval("document.activeElement.blur()")
settle(0.3)
page.eval("""(() => {
const b = [...document.querySelectorAll('button.help-tip')]
.find(x => x.offsetParent !== null);
b.click();
})()""")
settle(0.4)
chk("tapping a badge opens the tooltip and says so with aria-expanded",
page.eval("!!(document.getElementById('wp-tip-bubble') && !document.getElementById('wp-tip-bubble').hidden)")
and page.eval("!!document.querySelector(%s)"
% json.dumps('button.help-tip[aria-expanded="true"]')))
page.eval("document.body.click()")
settle(0.3)
chk("tapping elsewhere closes it",
page.eval("!document.getElementById('wp-tip-bubble') || document.getElementById('wp-tip-bubble').hidden"))
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
chk("no JavaScript errors anywhere in this run", not js_errors,
ascii_(js_errors[:2]))
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if server is not None:
try:
server.terminate()
except Exception:
pass
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())