fix(motion): harden accessible motion contract

This commit is contained in:
Cathryn Lavery
2026-08-12 15:31:48 -07:00
parent e2ed1925c4
commit 8a6623ad88
14 changed files with 498 additions and 79 deletions
+28 -8
View File
@@ -309,7 +309,7 @@ class AccessibleSvgParser(HTMLParser):
element.text.append(data)
def lint_accessible_svgs(text, expected_slug):
def lint_accessible_svgs(text, expected_slug, allow_template_placeholders=False):
parser = AccessibleSvgParser(text)
parser.feed(text)
parser.close()
@@ -352,7 +352,11 @@ def lint_accessible_svgs(text, expected_slug):
if "[" in element_id or "]" in element_id
)
)
if placeholder_ids:
allowed_placeholders = {"[diagram-slug]-title", "[diagram-slug]-desc"}
if placeholder_ids and (
not allow_template_placeholders
or not set(placeholder_ids).issubset(allowed_placeholders)
):
add(
svg.line,
svg.offset,
@@ -499,8 +503,12 @@ def named_families(value, allowed_fonts):
return families
def lint_text(text, colors, rgb_triplets, expected_slug):
findings = lint_accessible_svgs(text, expected_slug)
def lint_text(
text, colors, rgb_triplets, expected_slug, allow_template_placeholders=False
):
findings = lint_accessible_svgs(
text, expected_slug, allow_template_placeholders
)
allowed_fonts = ALLOWED_FONTS | style_guide_families()
resources = ResourceParser()
@@ -654,7 +662,7 @@ def parse_args():
parser.add_argument(
"--all",
action="store_true",
help="lint every skills/diagram-design/assets/example-*.html file",
help="lint every skills/diagram-design/assets/example-*.html and template*.html file",
)
parser.add_argument(
"--baseline",
@@ -677,7 +685,10 @@ def main():
skipped = 0
baseline = set()
if args.all:
paths = sorted(ASSET_DIR.glob("example-*.html"))
paths = sorted(
set(ASSET_DIR.glob("example-*.html"))
| set(ASSET_DIR.glob("template*.html"))
)
if args.baseline:
baseline = load_baseline()
for path in paths:
@@ -693,10 +704,19 @@ def main():
text = path.read_text(encoding="utf-8")
relative = display_path(path)
expected_slug = diagram_slug(path)
allow_template_placeholders = path.name.startswith("template")
if args.baseline and (path.name in baseline or relative in baseline):
findings = lint_accessible_svgs(text, expected_slug)
findings = lint_accessible_svgs(
text, expected_slug, allow_template_placeholders
)
else:
findings = lint_text(text, colors, rgb_triplets, expected_slug)
findings = lint_text(
text,
colors,
rgb_triplets,
expected_slug,
allow_template_placeholders,
)
except OSError as error:
findings = [(0, 0, "read-error", str(error))]
+18
View File
@@ -436,6 +436,13 @@ def main() -> int:
(project / "skills/diagram-design/assets/example-baseline.html").write_text(
'<svg style="color: #123456"></svg>\n', encoding="utf-8"
)
template_svg = VALID_SVG.replace(
"fixture-title", "[diagram-slug]-title"
).replace("fixture-desc", "[diagram-slug]-desc")
(project / "skills/diagram-design/assets/template.html").write_text(
'<img src="https://evil.example/template.png">\n' + template_svg,
encoding="utf-8",
)
baseline_result = subprocess.run(
[
sys.executable,
@@ -463,7 +470,18 @@ def main() -> int:
"baseline-a11y: legacy visual finding was not exempted\n"
f"{baseline_result.stdout}"
)
if "external-asset: external resource in <img> src is not allowed" not in baseline_result.stdout:
raise AssertionError(
"template-scan: --all did not lint template HTML\n"
f"{baseline_result.stdout}"
)
if "unresolved placeholder" in baseline_result.stdout:
raise AssertionError(
"template-scan: canonical template placeholders were rejected\n"
f"{baseline_result.stdout}"
)
print("OK: baseline file receives a11y checks but keeps visual exemptions")
print("OK: --all scans templates and permits canonical slug placeholders")
print("All accessible-SVG lint cases passed.")
return 0
+160 -3
View File
@@ -104,9 +104,10 @@ def main() -> int:
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")
required_shipped = {TEMPLATE, EXAMPLE}
if not required_shipped.issubset(set(shipped)):
raise AssertionError(f"incomplete shipped motion inventory: {shipped}")
print("OK: required shipped motion inventory is parsed and complete")
check(
"bad-mode",
source.replace('data-motion-mode="step"', 'data-motion-mode="cinematic"', 1),
@@ -132,6 +133,19 @@ def main() -> int:
source.replace('aria-live="polite"', 'aria-live="assertive"', 1),
"motion status needs role=status",
)
status_line = (
' <p class="sr-only" data-motion-status role="status" '
'aria-live="polite" aria-atomic="true"></p>'
)
check(
"status-inside-hidden-controls",
source.replace(
f" </div>\n{status_line}",
f"{status_line}\n </div>",
1,
),
"motion status must be outside data-motion-controls",
)
check(
"hidden-fallback",
source.replace('data-motion-item data-step="1"', 'data-motion-item data-step="1" style="opacity:0"', 1),
@@ -147,6 +161,15 @@ def main() -> int:
source.replace("prefers-reduced-motion", "user-reduced-motion"),
"missing reduced-motion CSS fallback",
)
check(
"comment-cannot-spoof-reduced-motion",
source.replace(
"@media (prefers-reduced-motion: reduce)",
"@media (user-reduced-motion: reduce)",
1,
).replace("</style>", " /* prefers-reduced-motion */\n </style>", 1),
"missing reduced-motion CSS fallback",
)
check(
"reduced-motion-controls-visible",
source.replace(
@@ -161,6 +184,15 @@ def main() -> int:
source.replace("[data-motion-controls][hidden] { display: none !important; }", "", 1),
"missing hidden control override",
)
check(
"comment-cannot-spoof-script-contract",
source.replace("'visibilitychange'", "'pagehide'", 1).replace(
" })();",
" // visibilitychange\n })();",
1,
),
"missing background-tab pause",
)
check(
"missing-static-override",
source.replace('data-motion="static"', 'data-motion="still"'),
@@ -180,6 +212,61 @@ def main() -> int:
source.replace('aria-label="Step 1: Request received"', 'aria-description="accent stage"', 1),
"needs a non-color aria-label",
)
check(
"unicode-step-count",
source.replace('data-step-count="5"', 'data-step-count="٥"', 1),
"data-step-count must be an ASCII decimal integer",
)
check(
"underscored-item-step",
source.replace('data-step="1"', 'data-step="1_0"', 1),
"non-ASCII-decimal data-step",
)
check(
"double-space-ready-scope",
source.replace(
".motion-ready [data-motion-item] {",
".motion-ready [data-motion-item] {",
1,
),
None,
)
check(
"comma-unscoped-opacity",
source.replace(
".motion-ready [data-motion-item] {\n opacity: .12;",
".motion-ready [data-motion-item], [data-motion-item] {\n opacity: 0;",
1,
),
"unscoped data-motion-item hiding",
)
check(
"unscoped-visibility",
source.replace(
"</style>",
" [data-motion-root] > [data-motion-item] { visibility: hidden; }\n </style>",
1,
),
"unscoped data-motion-item hiding",
)
check(
"unscoped-display",
source.replace(
"</style>",
" [data-motion-item] { display: none; }\n </style>",
1,
),
"unscoped data-motion-item hiding",
)
check(
"unscoped-infinite-iteration-count",
source.replace(
"</style>",
" .motion-ready [data-motion-item] { animation-iteration-count: infinite; }\n </style>",
1,
),
"infinite animation must be scoped to data-motion-mode=loop",
)
check(
"null-test-frame",
source.replace("requestedStep !== null", "true", 1),
@@ -195,6 +282,15 @@ def main() -> int:
source.replace("/^\\d+$/.test(requestedStep)", "/^-?\\d+$/.test(requestedStep)", 1),
"missing non-negative decimal test frame validation",
)
check(
"modified-replay-shortcut",
source.replace(
"!event.ctrlKey && !event.metaKey && !event.altKey && ",
"",
1,
),
"missing modified-shortcut guard",
)
check(
"over-budget-test-frame",
source.replace(" && parsedStep <= count", "", 1),
@@ -209,6 +305,16 @@ def main() -> int:
),
"must exactly match the controller in template-motion.html",
)
ready_line = " root.classList.add('motion-ready');\n"
check(
"early-motion-ready",
source.replace(ready_line, "", 1).replace(
" if (staticOverride) {",
ready_line + " if (staticOverride) {",
1,
),
"motion-ready must be added only after the initial render succeeds",
)
check(
"remote-controller",
source.replace(
@@ -257,6 +363,57 @@ def main() -> int:
raise AssertionError(f"broken Trace B connector was accepted: {trace_errors}")
print("OK: Trace B connector cannot continue through terminal rows")
decorative_example = directory / "decorative-trace.html"
decorative_example.write_text(
EXAMPLE.read_text(encoding="utf-8").replace(
" </svg>",
' <circle data-motion-item data-step="3" data-motion-decorative '
'aria-hidden="true" focusable="false" cx="0" cy="0" r="1"/>\n'
" </svg>",
1,
),
encoding="utf-8",
)
decorative_errors = semantic_module.verify_example(decorative_example)
if decorative_errors:
raise AssertionError(
f"decorative semantic-verifier item was rejected: {decorative_errors}"
)
print("OK: semantic verifier accepts an accessible decorative motion item")
original_skill = semantic_module.SKILL
try:
missing_router = directory / "missing-router.md"
missing_router.write_text(
original_skill.read_text(encoding="utf-8").replace(
"semantic-patterns.md", "patterns.md"
),
encoding="utf-8",
)
semantic_module.SKILL = missing_router
markdown_errors = semantic_module.verify_markdown()
finally:
semantic_module.SKILL = original_skill
if not any("must link to semantic-patterns.md" in error for error in markdown_errors):
raise AssertionError(f"missing semantic router was accepted: {markdown_errors}")
print("OK: missing semantic-pattern router anchor is rejected")
try:
missing_guide = directory / "missing-guide.md"
missing_guide.write_text(
original_skill.read_text(encoding="utf-8").replace(
"### Visual-type guide (27)", "### Visual guide"
),
encoding="utf-8",
)
semantic_module.SKILL = missing_guide
markdown_errors = semantic_module.verify_markdown()
finally:
semantic_module.SKILL = original_skill
if not any("must contain the 27-row visual-type guide" in error for error in markdown_errors):
raise AssertionError(f"missing visual-guide anchor was accepted: {markdown_errors}")
print("OK: missing visual-type guide anchor is rejected")
cli_result = subprocess.run(
[sys.executable, str(VERIFIER), str(TEMPLATE)],
capture_output=True,
+208 -23
View File
@@ -15,6 +15,7 @@ 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"}
ASCII_DECIMAL_RE = re.compile(r"^[0-9]+$")
class MotionParser(HTMLParser):
@@ -25,14 +26,18 @@ class MotionParser(HTMLParser):
self.actions: set[str] = set()
self.controls = 0
self.statuses: list[dict[str, str]] = []
self.statuses_in_controls = 0
self.scripts: list[dict[str, object]] = []
self.styles: 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._current_style: dict[str, object] | None = None
self._element_stack: list[str] = []
self._motion_root_depth: int | None = None
self._controls_depth: int | None = None
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.casefold()
@@ -48,10 +53,14 @@ class MotionParser(HTMLParser):
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 self._controls_depth is None:
self._controls_depth = len(self._element_stack)
if "data-motion-status" in data:
self.statuses.append(data)
if self._controls_depth is not None:
self.statuses_in_controls += 1
if tag == "script":
self._current_script = {
"attrs": data,
@@ -60,6 +69,9 @@ class MotionParser(HTMLParser):
"closed": False,
}
self.scripts.append(self._current_script)
if tag == "style":
self._current_style = {"body": [], "closed": False}
self.styles.append(self._current_style)
self._element_stack.append(tag)
if tag == "svg":
self._svg_depth = 1
@@ -80,6 +92,9 @@ class MotionParser(HTMLParser):
if tag == "script" and self._current_script is not None:
self._current_script["closed"] = True
self._current_script = None
if tag == "style" and self._current_style is not None:
self._current_style["closed"] = True
self._current_style = None
if self._svg_depth:
if tag in {"title", "desc"}:
self._capture = None
@@ -95,12 +110,21 @@ class MotionParser(HTMLParser):
and len(self._element_stack) <= self._motion_root_depth
):
self._motion_root_depth = None
if (
self._controls_depth is not None
and len(self._element_stack) <= self._controls_depth
):
self._controls_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._current_style is not None:
body = self._current_style["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)
@@ -118,6 +142,111 @@ def parsed_document(source: str) -> MotionParser:
return parser
def without_comments(source: str) -> str:
"""Remove JS/CSS comments while preserving quoted strings and regex literals."""
output: list[str] = []
quote: str | None = None
index = 0
while index < len(source):
character = source[index]
following = source[index + 1] if index + 1 < len(source) else ""
if quote is not None:
output.append(character)
if character == "\\" and index + 1 < len(source):
index += 1
output.append(source[index])
elif character == quote:
quote = None
elif character in {"'", '"', "`"}:
quote = character
output.append(character)
elif character == "/" and following == "*":
index += 2
while index < len(source) - 1 and source[index : index + 2] != "*/":
if source[index] in "\r\n":
output.append(source[index])
index += 1
index += 1
elif character == "/" and following == "/":
index += 2
while index < len(source) and source[index] not in "\r\n":
index += 1
if index < len(source):
output.append(source[index])
else:
output.append(character)
index += 1
return "".join(output)
def parsed_bodies(blocks: list[dict[str, object]]) -> str:
bodies = []
for block in blocks:
body = block["body"]
assert isinstance(body, list)
bodies.append("".join(body))
return without_comments("\n".join(bodies))
def css_at_rule_blocks(source: str, header_pattern: str) -> list[str]:
"""Return balanced bodies for CSS at-rules whose headers match a pattern."""
blocks: list[str] = []
for match in re.finditer(header_pattern + r"\s*\{", source, re.IGNORECASE):
depth = 1
index = match.end()
start = index
while index < len(source) and depth:
if source[index] == "{":
depth += 1
elif source[index] == "}":
depth -= 1
index += 1
if depth == 0:
blocks.append(source[start : index - 1])
return blocks
def hidden_unscoped_motion_selectors(source: str) -> list[str]:
"""Find selectors that hide motion items before progressive enhancement."""
hidden_declaration = re.compile(
r"(?:opacity\s*:\s*(?:0+(?:\.0*)?|\.0+)\b|"
r"visibility\s*:\s*hidden\b|display\s*:\s*none\b)",
re.IGNORECASE,
)
findings: list[str] = []
for rule in re.finditer(r"([^{}]+)\{([^{}]*)\}", source, re.DOTALL):
if not hidden_declaration.search(rule.group(2)):
continue
for selector in rule.group(1).split(","):
item = re.search(r"\[\s*data-motion-item\s*\]", selector, re.IGNORECASE)
if item is None:
continue
if ".motion-ready" not in selector[: item.start()]:
findings.append(" ".join(selector.split()))
return findings
def infinite_unscoped_selectors(source: str) -> list[str]:
"""Find infinite animation rules that are not limited to loop mode."""
infinite_declaration = re.compile(
r"(?:animation\s*:[^;}]*\binfinite\b|"
r"animation-iteration-count\s*:\s*infinite\b)",
re.IGNORECASE,
)
loop_scope = re.compile(
r"\[\s*data-motion-mode\s*=\s*['\"]loop['\"]\s*\]",
re.IGNORECASE,
)
findings: list[str] = []
for rule in re.finditer(r"([^{}]+)\{([^{}]*)\}", source, re.DOTALL):
if not infinite_declaration.search(rule.group(2)):
continue
for selector in rule.group(1).split(","):
if loop_scope.search(selector) is None:
findings.append(" ".join(selector.split()))
return findings
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"]:
@@ -160,11 +289,12 @@ def verify(path: Path) -> list[str]:
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:
raw_count = root.get("data-step-count", "")
if not ASCII_DECIMAL_RE.fullmatch(raw_count):
count = -1
errors.append("data-step-count must be an integer")
errors.append("data-step-count must be an ASCII decimal integer")
else:
count = int(raw_count)
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}")
@@ -174,11 +304,13 @@ def verify(path: Path) -> list[str]:
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")
raw_step = item.get("data-step", "")
if not ASCII_DECIMAL_RE.fullmatch(raw_step):
errors.append(
f"motion item {index} has a non-ASCII-decimal data-step"
)
continue
step = int(raw_step)
steps.append(step)
decorative = "data-motion-decorative" in item
if not decorative:
@@ -214,6 +346,10 @@ def verify(path: Path) -> list[str]:
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 parser.statuses_in_controls:
errors.append(
"motion status must be outside data-motion-controls so hidden controls do not hide live announcements"
)
if not parser.scripts:
errors.append("controlled mode needs the scoped control script")
@@ -248,20 +384,52 @@ def verify(path: Path) -> list[str]:
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",
script_source = parsed_bodies(parser.scripts)
style_source = parsed_bodies(parser.styles)
reduced_motion_css = "\n".join(
css_at_rule_blocks(
style_source,
r"@media\s*\(\s*prefers-reduced-motion\s*:\s*reduce\s*\)",
)
)
print_css = css_at_rule_blocks(style_source, r"@media\s+print\b")
if not reduced_motion_css:
errors.append("missing reduced-motion CSS fallback (prefers-reduced-motion)")
if not print_css:
errors.append("missing print CSS fallback (@media print)")
required_css = {
r"\[data-motion-controls\]\s*\{[^{}]*display\s*:\s*none\s*!important": (
reduced_motion_css,
"reduced-motion hidden controls",
"[data-motion-controls] { display: none !important; }",
),
r"\[data-motion-controls\]\[hidden\]\s*\{[^{}]*display\s*:\s*none\s*!important": (
style_source,
"hidden control override",
"[data-motion-controls][hidden] { display: none !important; }",
),
r"html\[data-motion\s*=\s*['\"]static['\"]\]": (
style_source,
"deterministic static override",
'data-motion="static"',
),
}
if parser.scripts:
required_source.update(
for pattern, (scope, label, example) in required_css.items():
if re.search(pattern, scope, re.IGNORECASE | re.DOTALL) is None:
errors.append(f"missing {label} ({example})")
required_script: dict[str, str] = {}
if parser.scripts:
required_script.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",
"!event.ctrlKey && !event.metaKey && !event.altKey": "modified-shortcut guard",
"motion-ready": "progressive enhancement class",
"requestedStep !== null": "non-null test frame validation",
"/^\\d+$/.test(requestedStep)": "non-negative decimal test frame validation",
@@ -271,14 +439,31 @@ def verify(path: Path) -> list[str]:
"playback controls unavailable": "static/reduced-motion status",
}
)
for needle, label in required_source.items():
if needle not in source:
for needle, label in required_script.items():
if needle not in script_source:
errors.append(f"missing {label} ({needle})")
ready_position = script_source.find("root.classList.add('motion-ready');")
initialization_position = script_source.find("if (staticOverride)")
if (
parser.scripts
and ready_position >= 0
and initialization_position >= 0
and ready_position < initialization_position
):
errors.append("motion-ready must be added only after the initial render succeeds")
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")
unscoped_selectors = hidden_unscoped_motion_selectors(style_source)
if unscoped_selectors:
errors.append(
"unscoped data-motion-item hiding breaks the no-JS fallback: "
+ ", ".join(unscoped_selectors)
)
infinite_selectors = infinite_unscoped_selectors(style_source)
if infinite_selectors:
errors.append(
"infinite animation must be scoped to data-motion-mode=loop: "
+ ", ".join(infinite_selectors)
)
if mode == "loop" and len(parser.items) > 2:
errors.append("loop mode may contain at most one semantic item and one decorative token")
+17 -3
View File
@@ -20,7 +20,7 @@ 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
MAX_SKILL_BYTES = 40_000
PATTERN_NAMES = (
"Fan-in queue / bottleneck",
@@ -190,7 +190,13 @@ def verify_markdown() -> list[str]:
)
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)"):
router_position = skill.find("semantic-patterns.md")
guide_position = skill.find("### Visual-type guide (27)")
if router_position < 0:
errors.append("SKILL.md must link to semantic-patterns.md")
if guide_position < 0:
errors.append("SKILL.md must contain the 27-row visual-type guide")
if router_position >= 0 and guide_position >= 0 and router_position > guide_position:
errors.append("semantic-pattern router must precede the visual-type guide")
visual_guide = section(skill, "### Visual-type guide (27)", "Rules of thumb:")
@@ -324,9 +330,17 @@ def verify_example(path: Path = EXAMPLE) -> list[str]:
steps: list[int] = []
for item in parser.motion_items:
try:
steps.append(int(item.get("data-step", "")))
step = int(item.get("data-step", ""))
except ValueError:
errors.append("every motion item needs an integer data-step")
continue
if "data-motion-decorative" in item:
if item.get("aria-hidden") != "true" or item.get("focusable") != "false":
errors.append(
"decorative motion items need aria-hidden=true and focusable=false"
)
continue
steps.append(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]: