[verified] add semantic patterns and accessible motion
This commit is contained in:
+223
-20
@@ -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(),
|
||||
|
||||
@@ -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	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="<script>alert(1)</script>"></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"
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user