[verified] add semantic patterns and accessible motion

This commit is contained in:
Cathryn Lavery
2026-08-12 14:08:50 -07:00
parent 3c5c34ba3b
commit e2ed1925c4
19 changed files with 2813 additions and 55 deletions
+223 -20
View File
@@ -6,17 +6,20 @@ skin. Use ``--all --baseline`` to skip those documented legacy files.
"""
import argparse
import hashlib
import re
import sys
from dataclasses import dataclass, field
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import parse_qs, urlparse
ROOT = Path(__file__).resolve().parent.parent
STYLE_GUIDE = ROOT / "skills/diagram-design/references/style-guide.md"
ASSET_DIR = ROOT / "skills/diagram-design/assets"
BASELINE = ROOT / "scripts/lint-skin-baseline.txt"
MOTION_TEMPLATE = ASSET_DIR / "template-motion.html"
HEX_RE = re.compile(
r"(?<![\w-])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9a-fA-F])"
@@ -26,14 +29,21 @@ RGBA_RE = re.compile(
re.IGNORECASE,
)
BLACK_RGB_RE = re.compile(r"rgb\(\s*0\s*,\s*0\s*,\s*0\s*\)", re.IGNORECASE)
SCRIPT_RE = re.compile(r"<script\b", re.IGNORECASE)
SRC_HTTP_RE = re.compile(r"\bsrc\s*=\s*(['\"])\s*https?://", re.IGNORECASE)
IMPORT_HTTP_RE = re.compile(
r"@import\s+(?:url\(\s*)?(?:['\"]\s*)?https?://", re.IGNORECASE
SCRIPT_OPEN_RE = re.compile(r"<script\b(?P<attrs>[^>]*)>", re.IGNORECASE | re.DOTALL)
SCRIPT_BLOCK_RE = re.compile(
r"<script\b(?P<attrs>[^>]*)>(?P<body>.*?)</script\s*>",
re.IGNORECASE | re.DOTALL,
)
URL_HTTP_RE = re.compile(r"url\(\s*(?:['\"]\s*)?https?://", re.IGNORECASE)
SRC_HTTP_RE = re.compile(r"\bsrc\s*=\s*(['\"])\s*(?:https?:)?//", re.IGNORECASE)
IMPORT_HTTP_RE = re.compile(
r"@import\s+(?:url\(\s*)?(?:['\"]\s*)?(?:https?:)?//", re.IGNORECASE
)
URL_HTTP_RE = re.compile(r"url\(\s*(?:['\"]\s*)?(?:https?:)?//", re.IGNORECASE)
IMPORT_ANY_RE = re.compile(r"@import\b", re.IGNORECASE)
URL_ANY_RE = re.compile(r"url\(\s*([^)]+?)\s*\)", re.IGNORECASE)
LINK_RE = re.compile(r"<link\b[^>]*>", re.IGNORECASE | re.DOTALL)
HREF_RE = re.compile(r"\bhref\s*=\s*(['\"])(.*?)\1", re.IGNORECASE | re.DOTALL)
REL_RE = re.compile(r"\brel\s*=\s*(['\"])(.*?)\1", re.IGNORECASE | re.DOTALL)
FONT_CSS_RE = re.compile(r"font-family\s*:\s*([^;}]+)", re.IGNORECASE)
FONT_ATTR_RE = re.compile(
r"\bfont-family\s*=\s*(['\"])(.*?)\1", re.IGNORECASE | re.DOTALL
@@ -52,6 +62,137 @@ ALLOWED_FONTS = {
CSS_FONT_KEYWORDS = {"inherit", "initial", "revert", "revert-layer", "unset"}
def normalized_controller(body):
"""Return a platform-stable representation of an inline controller."""
return body.replace("\r\n", "\n").replace("\r", "\n").strip()
def canonical_controller_digest():
"""Hash the one reviewed controller shipped by the motion template."""
source = MOTION_TEMPLATE.read_text(encoding="utf-8")
blocks = list(SCRIPT_BLOCK_RE.finditer(source))
if len(blocks) != 1:
raise RuntimeError("template-motion.html must contain exactly one controller")
body = normalized_controller(blocks[0].group("body"))
return hashlib.sha256(body.encode("utf-8")).hexdigest()
def google_fonts_families(url):
"""Return exact families from an approved Google Fonts css2 stylesheet."""
try:
parsed = urlparse(url)
port = parsed.port
except ValueError:
return set()
if (
parsed.scheme.casefold() != "https"
or parsed.hostname is None
or parsed.hostname.casefold() != "fonts.googleapis.com"
or port not in (None, 443)
or parsed.path != "/css2"
or parsed.fragment
):
return set()
families = set()
for value in parse_qs(parsed.query, keep_blank_values=True).get("family", []):
family = value.split(":", 1)[0].strip()
if family:
families.add(family.casefold())
return families
class ResourceParser(HTMLParser):
"""Collect remote resource dependencies without executing the document."""
RESOURCE_ATTRS = {
"base": ("href",),
"link": ("href",),
"script": ("src",),
"img": ("src", "srcset"),
"iframe": ("src",),
"object": ("data",),
"embed": ("src",),
"image": ("href", "xlink:href"),
"use": ("href", "xlink:href"),
"video": ("src", "poster"),
"audio": ("src",),
"source": ("src", "srcset"),
}
def __init__(self):
super().__init__(convert_charrefs=True)
self.remote: list[tuple[int, str, str, str]] = []
self.executable: list[tuple[int, str, str]] = []
self.fonts: set[str] = set()
@staticmethod
def is_remote(value):
try:
parsed = urlparse(value.strip())
except ValueError:
return True
return bool(parsed.scheme) or bool(parsed.netloc)
def handle_starttag(self, tag, attrs):
tag = tag.casefold()
data = {name.casefold(): value or "" for name, value in attrs}
line, _column = self.getpos()
for name, value in data.items():
compact = "".join(character for character in value if ord(character) > 32).casefold()
if (
name.startswith("on")
or name == "srcdoc"
or tag == "base"
or compact.startswith("javascript:")
or compact.startswith("data:text/html")
):
self.executable.append((line, tag, name))
for attr in self.RESOURCE_ATTRS.get(tag, ()):
value = data.get(attr, "").strip()
candidates = (
[item.strip().split()[0] for item in value.split(",") if item.strip()]
if attr == "srcset"
else [value]
)
for candidate in candidates:
if not candidate or not self.is_remote(candidate):
continue
rel = data.get("rel", "").casefold().split()
approved_fonts = (
google_fonts_families(candidate)
if tag == "link" and attr == "href" and "stylesheet" in rel
else set()
)
if approved_fonts:
self.fonts.update(approved_fonts)
else:
self.remote.append((line, tag, attr, candidate))
def style_guide_families():
"""Read only the Family cells in the style guide's Typography table."""
markdown = STYLE_GUIDE.read_text(encoding="utf-8")
match = re.search(
r"^## Typography\s*$.*?^\| Role \| Family \|.*?\n(?P<rows>.*?)(?=^### |^## |\Z)",
markdown,
re.MULTILINE | re.DOTALL,
)
if not match:
return set()
families = set()
for line in match.group("rows").splitlines():
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
if len(cells) < 2 or not cells[0].startswith("`"):
continue
family_stack = re.sub(r"\s*\([^)]*\)\s*$", "", cells[1]).strip(" `*")
family_stack = re.sub(r"\s+\*[^*]+\*\s*$", "", family_stack).strip()
for family in family_stack.split(","):
family = family.strip().strip("'\"")
if family:
families.add(family.casefold())
return families
@dataclass
class SvgTextElement:
tag: str
@@ -346,20 +487,44 @@ def diagram_slug(path):
return path.stem.removeprefix("example-")
def named_families(value):
def named_families(value, allowed_fonts):
families = []
for raw_family in value.split(","):
family = raw_family.strip().strip("'\"").strip()
lowered = family.casefold()
if not family or lowered in CSS_FONT_KEYWORDS or lowered.startswith("var("):
continue
if lowered not in ALLOWED_FONTS:
if lowered not in allowed_fonts:
families.append(family)
return families
def lint_text(text, colors, rgb_triplets, expected_slug):
findings = lint_accessible_svgs(text, expected_slug)
allowed_fonts = ALLOWED_FONTS | style_guide_families()
resources = ResourceParser()
resources.feed(text)
resources.close()
allowed_fonts.update(resources.fonts)
for line, tag, attr, _value in resources.remote:
findings.append(
(
line,
0,
"external-asset",
f"external resource in <{tag}> {attr} is not allowed",
)
)
for line, tag, attr in resources.executable:
findings.append(
(
line,
0,
"script",
f"executable attribute {attr} on <{tag}> is not allowed",
)
)
def add(offset, category, message):
findings.append((line_number(text, offset), offset, category, message))
@@ -384,35 +549,73 @@ def lint_text(text, colors, rgb_triplets, expected_slug):
for match in BLACK_RGB_RE.finditer(text):
add(match.start(), "pure-black", "pure black rgb(0,0,0) is not allowed")
for match in SCRIPT_RE.finditer(text):
add(match.start(), "script", "<script> tags are not allowed")
script_blocks = {match.start(): match for match in SCRIPT_BLOCK_RE.finditer(text)}
script_openings = list(SCRIPT_OPEN_RE.finditer(text))
if len(script_openings) > 1:
add(
script_openings[1].start(),
"script",
"only one canonical motion controller is allowed",
)
canonical_digest = None
for match in script_openings:
attrs = match.group("attrs")
block = script_blocks.get(match.start())
canonical_attrs = attrs.strip().casefold() == "data-diagram-controls"
if not canonical_attrs:
add(
match.start(),
"script",
"only the canonical data-diagram-controls attribute is allowed",
)
elif block is None:
add(
match.start(),
"script",
"motion controller must have a closing script tag",
)
else:
if canonical_digest is None:
canonical_digest = canonical_controller_digest()
body = normalized_controller(block.group("body"))
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
if digest != canonical_digest:
add(
match.start(),
"script",
"motion controller must exactly match template-motion.html",
)
for match in SRC_HTTP_RE.finditer(text):
add(match.start(), "external-asset", "external HTTP(S) src is not allowed")
import_spans = []
for match in IMPORT_HTTP_RE.finditer(text):
import_spans.append(match.span())
add(match.start(), "external-asset", "external HTTP(S) @import is not allowed")
for match in IMPORT_ANY_RE.finditer(text):
add(match.start(), "external-asset", "CSS @import is not allowed")
for match in URL_HTTP_RE.finditer(text):
if any(start <= match.start() < end for start, end in import_spans):
for match in URL_ANY_RE.finditer(text):
value = match.group(1).strip().strip("'\"").strip()
if value.startswith("#"):
continue
add(match.start(), "external-asset", "external HTTP(S) url() is not allowed")
add(match.start(), "external-asset", "non-fragment CSS url() is not allowed")
for link_match in LINK_RE.finditer(text):
href_match = HREF_RE.search(link_match.group())
if not href_match:
continue
href = href_match.group(2).strip()
if href.lower().startswith("https://fonts.googleapis.com"):
approved_families = google_fonts_families(href)
rel_match = REL_RE.search(link_match.group())
rel_values = rel_match.group(2).casefold().split() if rel_match else []
if approved_families and "stylesheet" in rel_values:
allowed_fonts.update(approved_families)
continue
if href.lower().startswith(("http://", "https://")):
parsed_href = urlparse(href)
if parsed_href.scheme.casefold() in {"http", "https"} or parsed_href.netloc:
href_offset = link_match.start() + href_match.start(2)
add(href_offset, "external-asset", "external HTTP(S) <link> is not allowed")
for match in FONT_CSS_RE.finditer(text):
unsupported = named_families(match.group(1))
unsupported = named_families(match.group(1), allowed_fonts)
if unsupported:
add(
match.start(),
@@ -421,7 +624,7 @@ def lint_text(text, colors, rgb_triplets, expected_slug):
)
for match in FONT_ATTR_RE.finditer(text):
unsupported = named_families(match.group(2))
unsupported = named_families(match.group(2), allowed_fonts)
if unsupported:
add(
match.start(),
+244
View File
@@ -4,6 +4,7 @@
from __future__ import annotations
import importlib.util
import re
import shutil
import subprocess
import sys
@@ -14,6 +15,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
LINTER = ROOT / "scripts/lint-skin.py"
BUILD_ICONS = ROOT / "scripts/build-icons.py"
MOTION_TEMPLATE = ROOT / "skills/diagram-design/assets/template-motion.html"
VALID_SVG = """\
<svg xmlns="http://www.w3.org/2000/svg" role="img"
@@ -62,6 +64,19 @@ def require_pass(name: str, html: str, directory: Path) -> None:
print(f"OK: {name} accepted")
def require_lint_finding(
name: str, html: str, expected: str, directory: Path
) -> None:
path = directory / f"{name}.html"
path.write_text(html, encoding="utf-8")
result = run_linter(path)
if result.returncode != 1 or expected not in result.stdout:
raise AssertionError(
f"{name}: expected finding {expected!r}\n{result.stdout}{result.stderr}"
)
print(f"OK: {name} rejected — {expected}")
def main() -> int:
with tempfile.TemporaryDirectory(prefix="lint-a11y-") as temp_dir:
directory = Path(temp_dir)
@@ -147,6 +162,207 @@ def main() -> int:
'<path d="M0 0h1v1z"/></svg>\n',
directory,
)
template_source = MOTION_TEMPLATE.read_text(encoding="utf-8")
controller_match = re.search(
r"<script\b[^>]*data-diagram-controls[^>]*>(.*?)</script\s*>",
template_source,
re.IGNORECASE | re.DOTALL,
)
if controller_match is None:
raise AssertionError("canonical motion controller could not be extracted")
controller_body = controller_match.group(1)
canonical_script = f"<script data-diagram-controls>{controller_body}</script>\n"
motion_svg = VALID_SVG.replace("fixture-title", "motion-controls-title").replace(
"fixture-desc", "motion-controls-desc"
)
require_pass(
"motion-controls",
motion_svg + canonical_script,
directory,
)
duplicate_svg = VALID_SVG.replace(
"fixture-title", "duplicate-controller-title"
).replace("fixture-desc", "duplicate-controller-desc")
require_lint_finding(
"duplicate-controller",
duplicate_svg + canonical_script + canonical_script,
"script: only one canonical motion controller is allowed",
directory,
)
unscoped_svg = VALID_SVG.replace("fixture-title", "unscoped-script-title").replace(
"fixture-desc", "unscoped-script-desc"
)
require_lint_finding(
"unscoped-script",
unscoped_svg + "<script>void 0;</script>\n",
"script: only the canonical data-diagram-controls attribute is allowed",
directory,
)
controller_attacks = {
"network-controller": "fetch('/collect');",
"dynamic-html-controller": "document.body.innerHTML = '<p>changed</p>';",
"import-controller": "import('./remote.js');",
"eval-controller": "eval('1 + 1');",
"navigation-controller": "location.assign('https://example.invalid');",
"modified-controller": "void 0;",
}
for name, injected in controller_attacks.items():
attack_svg = VALID_SVG.replace(
"fixture-title", f"{name}-title"
).replace("fixture-desc", f"{name}-desc")
modified = controller_body.replace("(() => {", f"(() => {{\n {injected}", 1)
require_lint_finding(
name,
attack_svg + f"<script data-diagram-controls>{modified}</script>\n",
"script: motion controller must exactly match template-motion.html",
directory,
)
unclosed_svg = VALID_SVG.replace("fixture-title", "unclosed-script-title").replace(
"fixture-desc", "unclosed-script-desc"
)
require_lint_finding(
"unclosed-motion-script",
unclosed_svg + f"<script data-diagram-controls>{controller_body}\n",
"script: motion controller must have a closing script tag",
directory,
)
remote_svg = VALID_SVG.replace("fixture-title", "remote-script-title").replace(
"fixture-desc", "remote-script-desc"
)
require_lint_finding(
"remote-script",
remote_svg
+ f'<script data-diagram-controls src="https://example.invalid/c.js">{controller_body}</script>\n',
"script: only the canonical data-diagram-controls attribute is allowed",
directory,
)
type_svg = VALID_SVG.replace("fixture-title", "script-type-title").replace(
"fixture-desc", "script-type-desc"
)
require_lint_finding(
"script-type-override",
type_svg
+ f'<script data-diagram-controls type="application/json">{controller_body}</script>\n',
"script: only the canonical data-diagram-controls attribute is allowed",
directory,
)
inter_svg = VALID_SVG.replace("fixture-title", "inter-tight-title").replace(
"fixture-desc", "inter-tight-desc"
)
require_pass(
"inter-tight",
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;600&display=swap">\n'
'<style>text { font-family: "Inter Tight", sans-serif; }</style>\n'
+ inter_svg,
directory,
)
lookalike_svg = VALID_SVG.replace(
"fixture-title", "google-fonts-lookalike-title"
).replace("fixture-desc", "google-fonts-lookalike-desc")
require_lint_finding(
"google-fonts-lookalike",
'<link rel="stylesheet" href="https://fonts.googleapis.com.evil.test/css2?family=Inter+Tight">\n'
'<style>text { font-family: "Inter Tight", sans-serif; }</style>\n'
+ lookalike_svg,
"external-asset: external HTTP(S) <link> is not allowed",
directory,
)
bad_path_svg = VALID_SVG.replace(
"fixture-title", "google-fonts-bad-path-title"
).replace("fixture-desc", "google-fonts-bad-path-desc")
require_lint_finding(
"google-fonts-bad-path",
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2.evil?family=Inter+Tight">\n'
+ bad_path_svg,
"external-asset: external HTTP(S) <link> is not allowed",
directory,
)
protocol_relative_svg = VALID_SVG.replace(
"fixture-title", "protocol-relative-font-title"
).replace("fixture-desc", "protocol-relative-font-desc")
require_lint_finding(
"protocol-relative-font",
'<link rel="stylesheet" href="//fonts.googleapis.com/css2?family=Inter+Tight">\n'
+ protocol_relative_svg,
"external-asset: external HTTP(S) <link> is not allowed",
directory,
)
unquoted_remote_svg = VALID_SVG.replace(
"fixture-title", "unquoted-remote-title"
).replace("fixture-desc", "unquoted-remote-desc")
require_lint_finding(
"unquoted-remote-link",
'<link rel=stylesheet href=https://evil.example/x.css>\n'
+ unquoted_remote_svg,
"external-asset: external resource in <link> href is not allowed",
directory,
)
bypass_svg = VALID_SVG.replace("fixture-title", "resource-bypass-title").replace(
"fixture-desc", "resource-bypass-desc"
)
require_lint_finding(
"srcset-second-candidate",
'<img srcset="local.png 1x, https://evil.example/remote.png 2x">\n'
+ bypass_svg,
"external-asset: external resource in <img> srcset is not allowed",
directory,
)
require_lint_finding(
"protocol-relative-css-url",
'<style>.x { background: url(//evil.example/x.png); }</style>\n'
+ bypass_svg,
"external-asset: non-fragment CSS url() is not allowed",
directory,
)
require_lint_finding(
"event-handler",
'<div onclick="alert(1)"></div>\n' + bypass_svg,
"script: executable attribute onclick on <div> is not allowed",
directory,
)
require_lint_finding(
"javascript-url",
'<iframe src="javascript:alert(1)"></iframe>\n' + bypass_svg,
"script: executable attribute src on <iframe> is not allowed",
directory,
)
require_lint_finding(
"javascript-control-character",
'<iframe src="jav&#9;ascript:alert(1)"></iframe>\n' + bypass_svg,
"script: executable attribute src on <iframe> is not allowed",
directory,
)
require_lint_finding(
"base-href",
'<base href="https://evil.example/">\n' + bypass_svg,
"script: executable attribute href on <base> is not allowed",
directory,
)
require_lint_finding(
"iframe-srcdoc",
'<iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;"></iframe>\n'
+ bypass_svg,
"script: executable attribute srcdoc on <iframe> is not allowed",
directory,
)
require_lint_finding(
"css-escaped-scheme",
'<style>.x { background: url(https\\3a //evil.example/x); }</style>\n'
+ bypass_svg,
"external-asset: non-fragment CSS url() is not allowed",
directory,
)
arbitrary_font_svg = VALID_SVG.replace(
"fixture-title", "arbitrary-font-title"
).replace("fixture-desc", "arbitrary-font-desc")
require_lint_finding(
"arbitrary-font",
'<style>text { font-family: "Unapproved Sans", sans-serif; }</style>\n'
+ arbitrary_font_svg,
"font-family: unsupported font family: Unapproved Sans",
directory,
)
spec = importlib.util.spec_from_file_location("build_icons", BUILD_ICONS)
if spec is None or spec.loader is None:
@@ -186,6 +402,34 @@ def main() -> int:
ROOT / "skills/diagram-design/references/style-guide.md",
project / "skills/diagram-design/references/style-guide.md",
)
project_style = project / "skills/diagram-design/references/style-guide.md"
project_style.write_text(
project_style.read_text(encoding="utf-8").replace(
"| `title` | Instrument Serif |",
"| `title` | Inter Tight |",
1,
),
encoding="utf-8",
)
brand_font_path = project / "style-guide-font.html"
brand_font_path.write_text(
VALID_SVG.replace("fixture-title", "style-guide-font-title")
.replace("fixture-desc", "style-guide-font-desc")
.replace("</svg>", '<text style="font-family: Inter Tight;">Brand</text></svg>'),
encoding="utf-8",
)
brand_font_result = subprocess.run(
[sys.executable, str(project / "scripts/lint-skin.py"), str(brand_font_path)],
capture_output=True,
text=True,
check=False,
)
if brand_font_result.returncode != 0:
raise AssertionError(
"style-guide font: expected pass\n"
f"{brand_font_result.stdout}{brand_font_result.stderr}"
)
print("OK: exact customized style-guide typography family accepted")
(project / "scripts/lint-skin-baseline.txt").write_text(
"example-baseline.html\n", encoding="utf-8"
)
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Adversarial tests for verify-motion.py and its canonical template."""
from __future__ import annotations
import importlib.util
import re
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VERIFIER = ROOT / "scripts/verify-motion.py"
SEMANTIC_VERIFIER = ROOT / "scripts/verify-semantic-motion.py"
TEMPLATE = ROOT / "skills/diagram-design/assets/template-motion.html"
EXAMPLE = ROOT / "skills/diagram-design/assets/example-policy-trace-animated.html"
def load_verifier():
spec = importlib.util.spec_from_file_location("verify_motion", VERIFIER)
if spec is None or spec.loader is None:
raise AssertionError("could not load verify-motion.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def load_semantic_verifier():
spec = importlib.util.spec_from_file_location("verify_semantic_motion", SEMANTIC_VERIFIER)
if spec is None or spec.loader is None:
raise AssertionError("could not load verify-semantic-motion.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def main() -> int:
module = load_verifier()
semantic_module = load_semantic_verifier()
source = TEMPLATE.read_text(encoding="utf-8")
with tempfile.TemporaryDirectory(prefix="verify-motion-") as temporary:
directory = Path(temporary)
def check(name: str, html: str, expected: str | None) -> None:
path = directory / f"{name}.html"
path.write_text(html, encoding="utf-8")
errors = module.verify(path)
if expected is None:
if errors:
raise AssertionError(f"{name}: expected pass, got {errors}")
print(f"OK: {name} accepted")
else:
if not any(expected in error for error in errors):
raise AssertionError(f"{name}: expected {expected!r}, got {errors}")
print(f"OK: {name} rejected — {expected}")
check("canonical-template", source, None)
script_free_none = re.sub(
r"<script\b[^>]*>.*?</script\s*>", "", source, flags=re.IGNORECASE | re.DOTALL
)
script_free_none = re.sub(
r"<div data-motion-controls.*?</div>", "", script_free_none, flags=re.DOTALL
)
script_free_none = re.sub(
r"<p class=\"sr-only\" data-motion-status.*?</p>", "", script_free_none, flags=re.DOTALL
)
script_free_none = re.sub(
r"\sdata-motion-item\sdata-step=\"\d+\"", "", script_free_none
)
script_free_none = script_free_none.replace(
'data-motion-mode="step" data-step-count="5"',
'data-motion-mode="none" data-step-count="0"',
1,
)
check("script-free-none", script_free_none, None)
none_with_controls = script_free_none.replace(
"</main>", '<div data-motion-controls></div></main>', 1
)
check(
"none-with-controls",
none_with_controls,
"none mode must not expose playback controls or live status",
)
loop_two = script_free_none.replace(
'data-motion-mode="none" data-step-count="0"',
'data-motion-mode="loop" data-step-count="2"',
1,
).replace(
"</main>",
'<span data-motion-item data-step="1" aria-label="one"></span>'
'<span data-motion-item data-step="2" aria-label="two"></span></main>',
1,
)
check(
"loop-two-semantic-items",
loop_two,
"loop mode allows at most one semantic item",
)
example_errors = module.verify(EXAMPLE)
if example_errors:
raise AssertionError(f"shipped example: expected pass, got {example_errors}")
print("OK: shipped animated example accepted")
shipped = module.shipped_motion_files()
if shipped != sorted([TEMPLATE, EXAMPLE], key=lambda path: path.as_posix()):
raise AssertionError(f"unexpected shipped motion inventory: {shipped}")
print("OK: shipped motion inventory is parsed and complete")
check(
"bad-mode",
source.replace('data-motion-mode="step"', 'data-motion-mode="cinematic"', 1),
"data-motion-mode must be one of",
)
check(
"too-many-steps",
source.replace('data-step-count="5"', 'data-step-count="9"', 1),
"semantic step count must be 1..8",
)
check(
"step-gap",
source.replace('data-step="4" aria-label="Step 4', 'data-step="3" aria-label="Step 4', 1),
"semantic steps must be contiguous",
)
check(
"missing-pause",
source.replace('data-motion-action="pause"', 'data-motion-action="stop"', 1),
"missing actions: pause",
)
check(
"bad-live-region",
source.replace('aria-live="polite"', 'aria-live="assertive"', 1),
"motion status needs role=status",
)
check(
"hidden-fallback",
source.replace('data-motion-item data-step="1"', 'data-motion-item data-step="1" style="opacity:0"', 1),
"fallback must be visible",
)
check(
"decorative-focus",
source.replace('data-motion-decorative aria-hidden="true" focusable="false"', 'data-motion-decorative aria-hidden="false" focusable="true"', 1),
"needs aria-hidden=true and focusable=false",
)
check(
"missing-reduced-motion",
source.replace("prefers-reduced-motion", "user-reduced-motion"),
"missing reduced-motion CSS fallback",
)
check(
"reduced-motion-controls-visible",
source.replace(
"[data-motion-controls] { display: none !important; }",
"[data-motion-controls] { display: flex !important; }",
1,
),
"missing reduced-motion hidden controls",
)
check(
"hidden-controls-overridden",
source.replace("[data-motion-controls][hidden] { display: none !important; }", "", 1),
"missing hidden control override",
)
check(
"missing-static-override",
source.replace('data-motion="static"', 'data-motion="still"'),
"missing deterministic static override",
)
check(
"late-title",
source.replace(
'<title id="template-motion-title">',
'<g></g><title id="template-motion-title">',
1,
),
"title must be its first child",
)
check(
"color-only-stage",
source.replace('aria-label="Step 1: Request received"', 'aria-description="accent stage"', 1),
"needs a non-color aria-label",
)
check(
"null-test-frame",
source.replace("requestedStep !== null", "true", 1),
"missing non-null test frame validation",
)
check(
"fractional-test-frame",
source.replace("Number.isSafeInteger(parsedStep)", "Number.isFinite(parsedStep)", 1),
"missing integer test frame validation",
)
check(
"negative-test-frame",
source.replace("/^\\d+$/.test(requestedStep)", "/^-?\\d+$/.test(requestedStep)", 1),
"missing non-negative decimal test frame validation",
)
check(
"over-budget-test-frame",
source.replace(" && parsedStep <= count", "", 1),
"missing bounded test frame validation",
)
check(
"modified-controller",
source.replace(
"const params = new URLSearchParams(location.search);",
"const params = new URLSearchParams(location.search); void 0;",
1,
),
"must exactly match the controller in template-motion.html",
)
check(
"remote-controller",
source.replace(
"<script data-diagram-controls>",
'<script data-diagram-controls src="https://example.invalid/controller.js">',
1,
),
"must carry only the canonical data-diagram-controls attribute",
)
check(
"duplicate-controller-attribute",
source.replace(
"<script data-diagram-controls>",
"<script data-diagram-controls data-diagram-controls>",
1,
),
"must carry only the canonical data-diagram-controls attribute",
)
controls_outside = source.replace(
" <div data-motion-controls",
" </main>\n <div data-motion-controls",
1,
).replace(" </main>\n\n <script data-diagram-controls>", "\n <script data-diagram-controls>", 1)
check(
"controls-outside-root",
controls_outside,
"controlled mode needs one in-root control group; found 0",
)
check(
"unclosed-controller",
source.replace("</script>", "", 1),
"must have a closing script tag",
)
broken_trace = directory / "broken-trace.html"
broken_trace.write_text(
EXAMPLE.read_text(encoding="utf-8").replace(
'data-trace-connector="trace-b" x1="936" y1="104" x2="936" y2="336"',
'data-trace-connector="trace-b" x1="936" y1="104" x2="936" y2="576"',
1,
),
encoding="utf-8",
)
trace_errors = semantic_module.verify_example(broken_trace)
if not any("must terminate at the first FAIL" in error for error in trace_errors):
raise AssertionError(f"broken Trace B connector was accepted: {trace_errors}")
print("OK: Trace B connector cannot continue through terminal rows")
cli_result = subprocess.run(
[sys.executable, str(VERIFIER), str(TEMPLATE)],
capture_output=True,
text=True,
check=False,
)
if cli_result.returncode != 0 or f"OK {TEMPLATE}" not in cli_result.stdout:
raise AssertionError(f"CLI smoke test failed\n{cli_result.stdout}{cli_result.stderr}")
print("OK: CLI accepts canonical template")
print("All motion contract tests passed.")
return 0
if __name__ == "__main__":
sys.exit(main())
+340
View File
@@ -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())
+416
View File
@@ -0,0 +1,416 @@
#!/usr/bin/env python3
"""Verify semantic-pattern routing and the animated example contract.
Uses only the Python standard library and never executes example JavaScript.
"""
from __future__ import annotations
import argparse
import re
import sys
from collections import Counter
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import parse_qs, urlparse
ROOT = Path(__file__).resolve().parent.parent
SKILL = ROOT / "skills/diagram-design/SKILL.md"
PATTERNS = ROOT / "skills/diagram-design/references/semantic-patterns.md"
ANIMATION = ROOT / "skills/diagram-design/references/animation.md"
EXAMPLE = ROOT / "skills/diagram-design/assets/example-policy-trace-animated.html"
MAX_SKILL_BYTES = 35_000
PATTERN_NAMES = (
"Fan-in queue / bottleneck",
"Stage framework with semantic slots",
"Unstructured input → structured artifact",
"Paired policy-evaluation traces",
"Secure paved road",
"Governance / control catalog",
"Compensating security layers",
)
PATTERN_FIELDS = (
"Selection triggers:",
"Required primitives:",
"Complexity budget:",
"Anti-patterns:",
"Static fallback:",
"Nearest visual type:",
)
MOTION_MODES = ("none", "reveal", "step", "loop")
MOTION_PRIMITIVES = (
"Path draw",
"Staggered reveal",
"Queue counter",
"Typing / field population",
"Policy evaluation",
"Flow token",
"Containment",
"Audit append",
)
CONTROL_ACTIONS = {"play", "pause", "replay", "prev", "next"}
def is_approved_google_fonts_stylesheet(value: str) -> bool:
try:
parsed = urlparse(value)
port = parsed.port
except ValueError:
return False
return (
parsed.scheme.casefold() == "https"
and parsed.hostname is not None
and parsed.hostname.casefold() == "fonts.googleapis.com"
and port in (None, 443)
and parsed.path == "/css2"
and not parsed.fragment
and any(
family.strip()
for family in parse_qs(parsed.query, keep_blank_values=True).get("family", [])
)
)
class ContractParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.ids: list[str] = []
self.controls: set[str] = set()
self.motion_roots: list[dict[str, str]] = []
self.motion_items: list[dict[str, str]] = []
self.statuses: list[dict[str, str]] = []
self.scripts: list[dict[str, str]] = []
self.remote_assets: list[tuple[str, str]] = []
self.trace_connectors: list[dict[str, str]] = []
self.trace_b_terminals: list[dict[str, str]] = []
self.trace_b_not_reached: list[dict[str, str]] = []
self.trace_b_outcomes: list[dict[str, str]] = []
self.svg_lines: list[dict[str, str]] = []
self.noscript_count = 0
self.svgs: list[dict[str, object]] = []
self._svg_depth = 0
self._current_svg: dict[str, object] | None = None
self._capture: str | None = None
def handle_starttag(
self, tag: str, attrs: list[tuple[str, str | None]]
) -> None:
data = {name.casefold(): value or "" for name, value in attrs}
if data.get("id"):
self.ids.append(data["id"])
if "data-motion-root" in data:
self.motion_roots.append(data)
if "data-motion-item" in data:
self.motion_items.append(data)
if data.get("data-motion-action"):
self.controls.add(data["data-motion-action"])
if data.get("role") == "status":
self.statuses.append(data)
if tag == "script":
self.scripts.append(data)
if tag == "noscript":
self.noscript_count += 1
if data.get("data-trace-connector") == "trace-b":
self.trace_connectors.append(data)
if data.get("data-trace-b-terminal") == "fail":
self.trace_b_terminals.append(data)
if data.get("data-trace-b-state") == "not-reached":
self.trace_b_not_reached.append(data)
if data.get("data-trace-b-outcome") == "denied":
self.trace_b_outcomes.append(data)
if tag == "line":
self.svg_lines.append(data)
for attr in ("src", "href"):
value = data.get(attr, "").strip()
try:
parsed_value = urlparse(value)
remote = parsed_value.scheme.casefold() in {"http", "https"} or bool(parsed_value.netloc)
except ValueError:
remote = True
if value and remote:
allowed_font = tag == "link" and attr == "href" and is_approved_google_fonts_stylesheet(value)
if not allowed_font:
self.remote_assets.append((tag, value))
if tag == "svg":
self._svg_depth = 1
self._current_svg = {
"attrs": data,
"first_child": None,
"title": {"attrs": {}, "text": ""},
"desc": {"attrs": {}, "text": ""},
}
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_child"] is None:
self._current_svg["first_child"] = tag
if tag in {"title", "desc"} and self._svg_depth == 2:
self._current_svg[tag] = {"attrs": data, "text": ""}
self._capture = tag
def handle_endtag(self, tag: str) -> 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
def handle_data(self, data: str) -> None:
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 section(markdown: str, heading: str, next_heading: str | None) -> str:
start = markdown.find(heading)
if start < 0:
return ""
end = markdown.find(next_heading, start + len(heading)) if next_heading else -1
return markdown[start:] if end < 0 else markdown[start:end]
def verify_markdown() -> list[str]:
errors: list[str] = []
skill_bytes = SKILL.read_bytes()
skill = skill_bytes.decode("utf-8")
patterns = PATTERNS.read_text(encoding="utf-8")
animation = ANIMATION.read_text(encoding="utf-8")
if len(skill_bytes) > MAX_SKILL_BYTES:
errors.append(
f"SKILL.md exceeds {MAX_SKILL_BYTES} bytes: {len(skill_bytes)} bytes"
)
if "Selection: semantic pattern, then visual type" not in skill:
errors.append("SKILL.md must choose semantic pattern before visual type")
if skill.find("semantic-patterns.md") > skill.find("### Visual-type guide (27)"):
errors.append("semantic-pattern router must precede the visual-type guide")
visual_guide = section(skill, "### Visual-type guide (27)", "Rules of thumb:")
visual_rows = re.findall(r"^\|.*\[type-[^)]+\.md\]", visual_guide, re.MULTILINE)
if len(visual_rows) != 27:
errors.append(f"visual-type guide must preserve 27 rows; found {len(visual_rows)}")
for index, name in enumerate(PATTERN_NAMES, 1):
if name not in skill:
errors.append(f"SKILL.md does not route semantic pattern: {name}")
next_heading = f"## {index + 1}." if index < len(PATTERN_NAMES) else "## Composition rules"
block = section(patterns, f"## {index}. {name}", next_heading)
if not block:
errors.append(f"semantic-patterns.md is missing section {index}: {name}")
continue
for field in PATTERN_FIELDS:
if f"**{field}**" not in block:
errors.append(f"{name} is missing required field {field}")
for mode in MOTION_MODES:
if f"`{mode}`" not in animation:
errors.append(f"animation.md is missing mode {mode}")
for primitive in MOTION_PRIMITIVES:
if f"**{primitive}**" not in animation:
errors.append(f"animation.md is missing primitive {primitive}")
required_animation_terms = (
"prefers-reduced-motion",
"CSS-only",
"inline JavaScript",
"Play, Pause, Replay, Previous, and Next",
"deterministic",
"static",
"color-only",
"screenshot",
"export",
)
for term in required_animation_terms:
if term not in animation:
errors.append(f"animation.md is missing contract term {term!r}")
return errors
def verify_example(path: Path = EXAMPLE) -> list[str]:
errors: list[str] = []
source = path.read_text(encoding="utf-8")
parser = ContractParser()
parser.feed(source)
parser.close()
duplicates = sorted(name for name, count in Counter(parser.ids).items() if count > 1)
if duplicates:
errors.append("duplicate HTML/SVG IDs: " + ", ".join(duplicates))
if parser.remote_assets:
errors.append(f"remote dependencies other than allowed fonts: {parser.remote_assets}")
if len(parser.scripts) != 1:
errors.append(f"expected one minimal inline control script; found {len(parser.scripts)}")
else:
script = parser.scripts[0]
if script.get("src"):
errors.append("remote or file-based scripts are forbidden")
if "data-diagram-controls" not in script:
errors.append("inline script must be scoped with data-diagram-controls")
if len(parser.motion_roots) != 1:
errors.append(f"expected one motion root; found {len(parser.motion_roots)}")
else:
root = parser.motion_roots[0]
if root.get("data-motion-mode") != "step":
errors.append("working example must use step mode")
if root.get("data-static-frame") != "complete":
errors.append("motion root must declare a complete static fallback")
if root.get("data-frame") != "static":
errors.append("source first frame must be static")
missing_controls = CONTROL_ACTIONS - parser.controls
if missing_controls:
errors.append("missing controls: " + ", ".join(sorted(missing_controls)))
if not parser.statuses:
errors.append("interactive example needs accessible status text")
else:
status = parser.statuses[0]
if status.get("aria-live") != "polite" or status.get("aria-atomic") != "true":
errors.append("status needs aria-live=polite and aria-atomic=true")
if parser.noscript_count != 1:
errors.append("example needs one explicit no-JavaScript fallback")
if (
len(parser.trace_connectors) != 1
or len(parser.trace_b_terminals) != 1
or len(parser.trace_b_not_reached) != 2
or len(parser.trace_b_outcomes) != 1
):
errors.append(
"Trace B needs one persistent connector, one terminal FAIL, "
"two NOT REACHED rows, and one denied outcome"
)
else:
connector = parser.trace_connectors[0]
terminal = parser.trace_b_terminals[0]
not_reached = parser.trace_b_not_reached
outcome = parser.trace_b_outcomes[0]
try:
connector_end = float(connector["y2"])
fail_end = float(terminal["y"]) + float(terminal["height"])
later_starts = [float(node["y"]) for node in not_reached] + [float(outcome["y"])]
connector_x = float(connector["x1"])
vertical = connector_x == float(connector["x2"])
continuations = [
line
for line in parser.svg_lines
if line is not connector
and float(line.get("x1", "nan")) == connector_x
and float(line.get("x2", "nan")) == connector_x
and max(float(line.get("y1", "nan")), float(line.get("y2", "nan"))) > fail_end
]
except (KeyError, ValueError):
errors.append("Trace B structural coordinates must be numeric")
else:
if (
not vertical
or connector_end != fail_end
or any(connector_end >= start for start in later_starts)
or continuations
):
errors.append(
"Trace B persistent connector must terminate at the first FAIL "
"before NOT REACHED rows and the denied outcome"
)
steps: list[int] = []
for item in parser.motion_items:
try:
steps.append(int(item.get("data-step", "")))
except ValueError:
errors.append("every motion item needs an integer data-step")
if not item.get("aria-label", "").strip():
errors.append("every semantic motion item needs an aria-label")
if steps != [1, 2, 3, 4, 5]:
errors.append(f"policy steps must be exactly [1, 2, 3, 4, 5]; found {steps}")
if len(parser.svgs) != 1:
errors.append(f"expected one SVG; found {len(parser.svgs)}")
else:
svg = parser.svgs[0]
attrs = svg["attrs"]
title = svg["title"]
desc = svg["desc"]
assert isinstance(attrs, dict) and isinstance(title, dict) and isinstance(desc, dict)
title_attrs = title.get("attrs", {})
desc_attrs = desc.get("attrs", {})
labelled = attrs.get("aria-labelledby", "").split()
if attrs.get("role") != "img" or svg["first_child"] != "title":
errors.append("SVG needs role=img and title as first child")
if not str(title.get("text", "")).strip() or not str(desc.get("text", "")).strip():
errors.append("SVG title and description must be non-empty")
if not isinstance(title_attrs, dict) or not isinstance(desc_attrs, dict):
errors.append("SVG title/description attributes could not be parsed")
elif labelled != [title_attrs.get("id"), desc_attrs.get("id")]:
errors.append("SVG aria-labelledby must resolve to title then description")
required_source = {
"@media (prefers-reduced-motion: reduce)": "reduced-motion fallback",
"@media print": "print/static fallback",
'data-static-frame="complete"': "complete source fallback",
"?motion=static": "static query documentation",
"params.get('motion') === 'static'": "static screenshot path",
"document.fonts.ready": "font-stable screenshot hook",
"visibilitychange": "background pause",
"ArrowRight": "right-arrow keyboard path",
"ArrowLeft": "left-arrow keyboard path",
"event.code === 'Space'": "space play/pause path",
"Home": "home/reset keyboard path",
"toLowerCase() === 'r'": "replay keyboard path",
"window.setTimeout": "deterministic timer",
"requestedStep !== null": "non-null test-step validation",
"Number.isSafeInteger(parsedStep)": "integer test-step validation",
"parsedStep <= count": "bounded test-step validation",
"playback controls unavailable": "static/reduced-motion browser status",
"PASS": "pass text state",
"FAIL": "fail text state",
"SKIPPED": "skipped text state",
"NOT REACHED": "not-reached text state",
"FIRST DIVERGENCE": "first-divergence marker",
}
for needle, label in required_source.items():
if needle not in source:
errors.append(f"missing {label}: {needle}")
if "setInterval" in source or "Math.random" in source:
errors.append("motion timing must not use setInterval or randomness")
if re.search(r"<script\b[^>]*\bsrc\s*=", source, re.IGNORECASE):
errors.append("example must not load any remote script")
return errors
def main() -> int:
argument_parser = argparse.ArgumentParser(description=__doc__)
group = argument_parser.add_mutually_exclusive_group()
group.add_argument("--markdown-only", action="store_true")
group.add_argument("--example-only", action="store_true")
args = argument_parser.parse_args()
errors = (
verify_markdown()
if args.markdown_only
else verify_example()
if args.example_only
else verify_markdown() + verify_example()
)
if errors:
print("Semantic/motion verification failed:")
for error in errors:
print(f"- {error}")
return 1
if not args.example_only:
print("OK: 7 semantic patterns route independently to the preserved 27 visual types")
print("OK: animation modes, primitives, static fallback, and accessibility contract")
if not args.markdown_only:
print("OK: policy-trace example controls, structure, reduced motion, and unique IDs")
return 0
if __name__ == "__main__":
sys.exit(main())