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
+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,