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