[verified] add semantic patterns and accessible motion
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify the optional diagram-motion contract with no third-party deps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ASSET_DIR = ROOT / "skills/diagram-design/assets"
|
||||
MOTION_TEMPLATE = ASSET_DIR / "template-motion.html"
|
||||
MODES = {"none", "reveal", "step", "loop"}
|
||||
ACTIONS = {"play", "pause", "replay", "prev", "next"}
|
||||
|
||||
|
||||
class MotionParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.roots: list[dict[str, str]] = []
|
||||
self.items: list[dict[str, str]] = []
|
||||
self.actions: set[str] = set()
|
||||
self.controls = 0
|
||||
self.statuses: list[dict[str, str]] = []
|
||||
self.scripts: list[dict[str, object]] = []
|
||||
self.svgs: list[dict[str, object]] = []
|
||||
self._svg_depth = 0
|
||||
self._current_svg: dict[str, object] | None = None
|
||||
self._capture: str | None = None
|
||||
self._current_script: dict[str, object] | None = None
|
||||
self._element_stack: list[str] = []
|
||||
self._motion_root_depth: int | None = None
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
tag = tag.casefold()
|
||||
normalized_attrs = [(key.casefold(), value or "") for key, value in attrs]
|
||||
data = {key: value for key, value in normalized_attrs}
|
||||
if "data-motion-root" in data:
|
||||
self.roots.append(data)
|
||||
if self._motion_root_depth is None:
|
||||
self._motion_root_depth = len(self._element_stack)
|
||||
in_motion_root = self._motion_root_depth is not None
|
||||
if in_motion_root:
|
||||
if "data-motion-item" in data:
|
||||
self.items.append(data)
|
||||
if "data-motion-action" in data:
|
||||
self.actions.add(data["data-motion-action"])
|
||||
if "data-motion-status" in data:
|
||||
self.statuses.append(data)
|
||||
if "data-motion-controls" in data:
|
||||
self.controls += 1
|
||||
if tag == "script":
|
||||
self._current_script = {
|
||||
"attrs": data,
|
||||
"attr_names": [name for name, _value in normalized_attrs],
|
||||
"body": [],
|
||||
"closed": False,
|
||||
}
|
||||
self.scripts.append(self._current_script)
|
||||
self._element_stack.append(tag)
|
||||
if tag == "svg":
|
||||
self._svg_depth = 1
|
||||
self._current_svg = {"attrs": data, "first": None, "title": {}, "desc": {}}
|
||||
self.svgs.append(self._current_svg)
|
||||
return
|
||||
if self._svg_depth:
|
||||
self._svg_depth += 1
|
||||
assert self._current_svg is not None
|
||||
if self._svg_depth == 2 and self._current_svg["first"] is None:
|
||||
self._current_svg["first"] = tag
|
||||
if tag in {"title", "desc"}:
|
||||
self._current_svg[tag] = {"attrs": data, "text": ""}
|
||||
self._capture = tag
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
tag = tag.casefold()
|
||||
if tag == "script" and self._current_script is not None:
|
||||
self._current_script["closed"] = True
|
||||
self._current_script = None
|
||||
if self._svg_depth:
|
||||
if tag in {"title", "desc"}:
|
||||
self._capture = None
|
||||
self._svg_depth -= 1
|
||||
if self._svg_depth == 0:
|
||||
self._current_svg = None
|
||||
for index in range(len(self._element_stack) - 1, -1, -1):
|
||||
if self._element_stack[index] == tag:
|
||||
del self._element_stack[index:]
|
||||
break
|
||||
if (
|
||||
self._motion_root_depth is not None
|
||||
and len(self._element_stack) <= self._motion_root_depth
|
||||
):
|
||||
self._motion_root_depth = None
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._current_script is not None:
|
||||
body = self._current_script["body"]
|
||||
assert isinstance(body, list)
|
||||
body.append(data)
|
||||
if self._capture and self._current_svg:
|
||||
node = self._current_svg[self._capture]
|
||||
assert isinstance(node, dict)
|
||||
node["text"] = str(node.get("text", "")) + data
|
||||
|
||||
|
||||
def normalized_controller(body: str) -> str:
|
||||
return body.replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||
|
||||
|
||||
def parsed_document(source: str) -> MotionParser:
|
||||
parser = MotionParser()
|
||||
parser.feed(source)
|
||||
parser.close()
|
||||
return parser
|
||||
|
||||
|
||||
def canonical_controller() -> str:
|
||||
parser = parsed_document(MOTION_TEMPLATE.read_text(encoding="utf-8"))
|
||||
if len(parser.scripts) != 1 or not parser.scripts[0]["closed"]:
|
||||
raise RuntimeError("template-motion.html must contain one closed controller")
|
||||
body = parser.scripts[0]["body"]
|
||||
assert isinstance(body, list)
|
||||
return normalized_controller("".join(body))
|
||||
|
||||
|
||||
def shipped_motion_files() -> list[Path]:
|
||||
"""Enumerate named and parsed motion HTML assets deterministically."""
|
||||
named = set(ASSET_DIR.glob("template-motion*.html"))
|
||||
named.update(ASSET_DIR.glob("example-*-animated*.html"))
|
||||
parsed = set()
|
||||
for path in ASSET_DIR.glob("*.html"):
|
||||
try:
|
||||
document = parsed_document(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError):
|
||||
continue
|
||||
has_motion_controller = any(
|
||||
isinstance(script["attrs"], dict)
|
||||
and "data-diagram-controls" in script["attrs"]
|
||||
for script in document.scripts
|
||||
)
|
||||
if document.roots or has_motion_controller:
|
||||
parsed.add(path)
|
||||
return sorted(named | parsed, key=lambda path: path.as_posix())
|
||||
|
||||
|
||||
def verify(path: Path) -> list[str]:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
parser = parsed_document(source)
|
||||
errors: list[str] = []
|
||||
|
||||
if len(parser.roots) != 1:
|
||||
errors.append(f"expected exactly one data-motion-root; found {len(parser.roots)}")
|
||||
return errors
|
||||
|
||||
root = parser.roots[0]
|
||||
mode = root.get("data-motion-mode", "")
|
||||
if mode not in MODES:
|
||||
errors.append(f"data-motion-mode must be one of {sorted(MODES)}; got {mode!r}")
|
||||
try:
|
||||
count = int(root.get("data-step-count", ""))
|
||||
except ValueError:
|
||||
count = -1
|
||||
errors.append("data-step-count must be an integer")
|
||||
minimum_count = 0 if mode == "none" else 1
|
||||
if count < minimum_count or count > 8:
|
||||
errors.append(f"semantic step count must be {minimum_count}..8; got {count}")
|
||||
|
||||
if len(parser.items) > 12:
|
||||
errors.append(f"motion item budget is 12; found {len(parser.items)}")
|
||||
steps: list[int] = []
|
||||
semantic_steps: list[int] = []
|
||||
for index, item in enumerate(parser.items, 1):
|
||||
try:
|
||||
step = int(item.get("data-step", ""))
|
||||
except ValueError:
|
||||
errors.append(f"motion item {index} has a non-integer data-step")
|
||||
continue
|
||||
steps.append(step)
|
||||
decorative = "data-motion-decorative" in item
|
||||
if not decorative:
|
||||
semantic_steps.append(step)
|
||||
if not item.get("aria-label", "").strip():
|
||||
errors.append(f"semantic motion item {index} needs a non-color aria-label")
|
||||
else:
|
||||
if item.get("aria-hidden") != "true" or item.get("focusable") != "false":
|
||||
errors.append(f"decorative motion item {index} needs aria-hidden=true and focusable=false")
|
||||
inline = item.get("style", "").replace(" ", "").lower()
|
||||
if any(token in inline for token in ("display:none", "visibility:hidden", "opacity:0")):
|
||||
errors.append(f"motion item {index} is hidden in source; fallback must be visible")
|
||||
|
||||
expected = set(range(1, count + 1)) if count > 0 else set()
|
||||
if set(semantic_steps) != expected:
|
||||
errors.append(f"semantic steps must be contiguous 1..{count}; found {sorted(set(semantic_steps))}")
|
||||
crowded = {step: number for step, number in Counter(semantic_steps).items() if number > 2}
|
||||
if crowded:
|
||||
errors.append(f"no more than two semantic items may share a step; found {crowded}")
|
||||
if steps and (min(steps) < 1 or (count > 0 and max(steps) > count)):
|
||||
errors.append("motion item data-step falls outside declared step count")
|
||||
|
||||
controlled = mode == "step" or (mode == "reveal" and bool(parser.scripts))
|
||||
if controlled:
|
||||
if parser.controls != 1:
|
||||
errors.append(f"controlled mode needs one in-root control group; found {parser.controls}")
|
||||
missing = ACTIONS - parser.actions
|
||||
if missing:
|
||||
errors.append(f"controlled mode is missing actions: {', '.join(sorted(missing))}")
|
||||
if not parser.statuses:
|
||||
errors.append("controlled mode needs data-motion-status")
|
||||
else:
|
||||
status = parser.statuses[0]
|
||||
if status.get("role") != "status" or status.get("aria-live") != "polite" or status.get("aria-atomic") != "true":
|
||||
errors.append("motion status needs role=status, aria-live=polite, aria-atomic=true")
|
||||
if not parser.scripts:
|
||||
errors.append("controlled mode needs the scoped control script")
|
||||
|
||||
if mode in {"none", "loop"} and parser.scripts:
|
||||
errors.append(f"{mode} mode must be script-free")
|
||||
if mode in {"none", "loop"} and (parser.controls or parser.actions or parser.statuses):
|
||||
errors.append(f"{mode} mode must not expose playback controls or live status")
|
||||
if mode == "loop":
|
||||
semantic_count = sum(
|
||||
1 for item in parser.items if "data-motion-decorative" not in item
|
||||
)
|
||||
decorative_count = len(parser.items) - semantic_count
|
||||
if semantic_count > 1 or decorative_count > 1:
|
||||
errors.append(
|
||||
"loop mode allows at most one semantic item and one decorative token"
|
||||
)
|
||||
if len(parser.scripts) > 1:
|
||||
errors.append(f"motion document may contain at most one script; found {len(parser.scripts)}")
|
||||
for number, script in enumerate(parser.scripts, 1):
|
||||
attrs = script["attrs"]
|
||||
attr_names = script["attr_names"]
|
||||
body = script["body"]
|
||||
assert isinstance(attrs, dict) and isinstance(attr_names, list) and isinstance(body, list)
|
||||
if not script["closed"]:
|
||||
errors.append(f"script {number} must have a closing script tag")
|
||||
if attr_names != ["data-diagram-controls"] or attrs.get("data-diagram-controls") != "":
|
||||
errors.append(
|
||||
f"script {number} must carry only the canonical data-diagram-controls attribute"
|
||||
)
|
||||
if normalized_controller("".join(body)) != canonical_controller():
|
||||
errors.append(
|
||||
f"script {number} must exactly match the controller in template-motion.html"
|
||||
)
|
||||
|
||||
required_source = {
|
||||
"prefers-reduced-motion": "reduced-motion CSS fallback",
|
||||
"@media print": "print CSS fallback",
|
||||
}
|
||||
if parser.scripts:
|
||||
required_source.update(
|
||||
{
|
||||
"[data-motion-controls] { display: none !important; }": "reduced-motion hidden controls",
|
||||
"[data-motion-controls][hidden] { display: none !important; }": "hidden control override",
|
||||
'data-motion="static"': "deterministic static override",
|
||||
"motion') === 'step'": "deterministic step override",
|
||||
"document.fonts": "font-stable screenshot hook",
|
||||
"visibilitychange": "background-tab pause",
|
||||
"ArrowRight": "keyboard stepping",
|
||||
"motion-ready": "progressive enhancement class",
|
||||
"requestedStep !== null": "non-null test frame validation",
|
||||
"/^\\d+$/.test(requestedStep)": "non-negative decimal test frame validation",
|
||||
"Number.isSafeInteger(parsedStep)": "integer test frame validation",
|
||||
"parsedStep <= count": "bounded test frame validation",
|
||||
"if (step >= count) { finish(); return; }": "immediate final-step stop",
|
||||
"playback controls unavailable": "static/reduced-motion status",
|
||||
}
|
||||
)
|
||||
for needle, label in required_source.items():
|
||||
if needle not in source:
|
||||
errors.append(f"missing {label} ({needle})")
|
||||
|
||||
if re.search(r"(?<!motion-ready)\s\[data-motion-item\]\s*\{[^}]*opacity\s*:\s*0", source, re.S):
|
||||
errors.append("unscoped data-motion-item opacity:0 breaks the no-JS fallback")
|
||||
if mode != "loop" and re.search(r"animation\s*:[^;}]*\binfinite\b", source):
|
||||
errors.append("only loop mode may declare an infinite animation shorthand")
|
||||
if mode == "loop" and len(parser.items) > 2:
|
||||
errors.append("loop mode may contain at most one semantic item and one decorative token")
|
||||
|
||||
if not parser.svgs:
|
||||
errors.append("motion document needs an accessible SVG")
|
||||
for number, svg in enumerate(parser.svgs, 1):
|
||||
attrs = svg["attrs"]
|
||||
assert isinstance(attrs, dict)
|
||||
if attrs.get("role") != "img":
|
||||
errors.append(f"svg {number} needs role=img")
|
||||
labelled = attrs.get("aria-labelledby", "").split()
|
||||
title = svg["title"]
|
||||
desc = svg["desc"]
|
||||
assert isinstance(title, dict) and isinstance(desc, dict)
|
||||
title_attrs = title.get("attrs", {})
|
||||
desc_attrs = desc.get("attrs", {})
|
||||
if svg["first"] != "title":
|
||||
errors.append(f"svg {number} title must be its first child")
|
||||
if not str(title.get("text", "")).strip() or not str(desc.get("text", "")).strip():
|
||||
errors.append(f"svg {number} needs non-empty title and desc")
|
||||
if not isinstance(title_attrs, dict) or not isinstance(desc_attrs, dict) or labelled != [title_attrs.get("id"), desc_attrs.get("id")]:
|
||||
errors.append(f"svg {number} aria-labelledby must name title then desc")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
argument_parser = argparse.ArgumentParser(description=__doc__)
|
||||
argument_parser.add_argument("files", nargs="*", type=Path)
|
||||
argument_parser.add_argument(
|
||||
"--shipped",
|
||||
action="store_true",
|
||||
help="verify every named or parsed motion HTML asset shipped by the repository",
|
||||
)
|
||||
args = argument_parser.parse_args()
|
||||
files = list(args.files)
|
||||
if args.shipped:
|
||||
files.extend(shipped_motion_files())
|
||||
files = list(dict.fromkeys(files))
|
||||
if not files:
|
||||
argument_parser.error("provide files or use --shipped")
|
||||
failed = False
|
||||
for path in files:
|
||||
try:
|
||||
errors = verify(path)
|
||||
except (OSError, UnicodeError) as exc:
|
||||
errors = [str(exc)]
|
||||
if errors:
|
||||
failed = True
|
||||
print(f"FAIL {path}")
|
||||
for error in errors:
|
||||
print(f" - {error}")
|
||||
else:
|
||||
print(f"OK {path}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user