help.js injects its stylesheet on every page, and its glossary pills used bare
class selectors (.pill-draft ... .pill-hold). The creator's Issue (hold) status
radio also carries the class pill-hold, so the injected rule painted that radio
error-red at ALL times - selected or not. Reported by Nick ('why is the issues
(hold) button illuminated at all times'), 2026-08-20.
Pre-existing, not from this branch: help.js has had the bare selectors since
the login-portal commit, and the creator's pill-hold class predates the R2
branch. Every glossary rule is now scoped to .ui-help-pill.pill-*, which the
glossary markup already carries. Verified live: unselected, the hold pill's
computed style now matches its neighbours exactly; selected, it is still the
red fill; the glossary's own Hold pill keeps its tint. helptip_check gains the
pin (13 -> 14): no bare .pill-* selector in help.js, ever again.
Item: S8 (the help component's app-wide surface).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
197 lines
8.8 KiB
Python
197 lines
8.8 KiB
Python
#!/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])
|
|
# The glossary pill classes are injected app-wide and MUST stay scoped:
|
|
# a bare .pill-hold painted the creator's Issue (hold) status radio
|
|
# error-red at all times (found 2026-08-20).
|
|
help_src = io.open(os.path.join(HTML, "help.js"), encoding="utf-8").read()
|
|
bare = re.findall(r"(?<!\.ui-help-pill)\.pill-[a-z]+(?=\s*\{)", help_src)
|
|
chk("help.js pill classes are scoped to .ui-help-pill (no bare .pill-*)",
|
|
not bare, bare[: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())
|