Files
diagram-design/scripts/verify-motion.py
T

341 lines
14 KiB
Python

#!/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())