[verified] add semantic patterns and accessible motion

This commit is contained in:
Cathryn Lavery
2026-08-12 14:08:50 -07:00
parent 3c5c34ba3b
commit e2ed1925c4
19 changed files with 2813 additions and 55 deletions
+244
View File
@@ -4,6 +4,7 @@
from __future__ import annotations
import importlib.util
import re
import shutil
import subprocess
import sys
@@ -14,6 +15,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
LINTER = ROOT / "scripts/lint-skin.py"
BUILD_ICONS = ROOT / "scripts/build-icons.py"
MOTION_TEMPLATE = ROOT / "skills/diagram-design/assets/template-motion.html"
VALID_SVG = """\
<svg xmlns="http://www.w3.org/2000/svg" role="img"
@@ -62,6 +64,19 @@ def require_pass(name: str, html: str, directory: Path) -> None:
print(f"OK: {name} accepted")
def require_lint_finding(
name: str, html: str, expected: str, directory: Path
) -> None:
path = directory / f"{name}.html"
path.write_text(html, encoding="utf-8")
result = run_linter(path)
if result.returncode != 1 or expected not in result.stdout:
raise AssertionError(
f"{name}: expected finding {expected!r}\n{result.stdout}{result.stderr}"
)
print(f"OK: {name} rejected — {expected}")
def main() -> int:
with tempfile.TemporaryDirectory(prefix="lint-a11y-") as temp_dir:
directory = Path(temp_dir)
@@ -147,6 +162,207 @@ def main() -> int:
'<path d="M0 0h1v1z"/></svg>\n',
directory,
)
template_source = MOTION_TEMPLATE.read_text(encoding="utf-8")
controller_match = re.search(
r"<script\b[^>]*data-diagram-controls[^>]*>(.*?)</script\s*>",
template_source,
re.IGNORECASE | re.DOTALL,
)
if controller_match is None:
raise AssertionError("canonical motion controller could not be extracted")
controller_body = controller_match.group(1)
canonical_script = f"<script data-diagram-controls>{controller_body}</script>\n"
motion_svg = VALID_SVG.replace("fixture-title", "motion-controls-title").replace(
"fixture-desc", "motion-controls-desc"
)
require_pass(
"motion-controls",
motion_svg + canonical_script,
directory,
)
duplicate_svg = VALID_SVG.replace(
"fixture-title", "duplicate-controller-title"
).replace("fixture-desc", "duplicate-controller-desc")
require_lint_finding(
"duplicate-controller",
duplicate_svg + canonical_script + canonical_script,
"script: only one canonical motion controller is allowed",
directory,
)
unscoped_svg = VALID_SVG.replace("fixture-title", "unscoped-script-title").replace(
"fixture-desc", "unscoped-script-desc"
)
require_lint_finding(
"unscoped-script",
unscoped_svg + "<script>void 0;</script>\n",
"script: only the canonical data-diagram-controls attribute is allowed",
directory,
)
controller_attacks = {
"network-controller": "fetch('/collect');",
"dynamic-html-controller": "document.body.innerHTML = '<p>changed</p>';",
"import-controller": "import('./remote.js');",
"eval-controller": "eval('1 + 1');",
"navigation-controller": "location.assign('https://example.invalid');",
"modified-controller": "void 0;",
}
for name, injected in controller_attacks.items():
attack_svg = VALID_SVG.replace(
"fixture-title", f"{name}-title"
).replace("fixture-desc", f"{name}-desc")
modified = controller_body.replace("(() => {", f"(() => {{\n {injected}", 1)
require_lint_finding(
name,
attack_svg + f"<script data-diagram-controls>{modified}</script>\n",
"script: motion controller must exactly match template-motion.html",
directory,
)
unclosed_svg = VALID_SVG.replace("fixture-title", "unclosed-script-title").replace(
"fixture-desc", "unclosed-script-desc"
)
require_lint_finding(
"unclosed-motion-script",
unclosed_svg + f"<script data-diagram-controls>{controller_body}\n",
"script: motion controller must have a closing script tag",
directory,
)
remote_svg = VALID_SVG.replace("fixture-title", "remote-script-title").replace(
"fixture-desc", "remote-script-desc"
)
require_lint_finding(
"remote-script",
remote_svg
+ f'<script data-diagram-controls src="https://example.invalid/c.js">{controller_body}</script>\n',
"script: only the canonical data-diagram-controls attribute is allowed",
directory,
)
type_svg = VALID_SVG.replace("fixture-title", "script-type-title").replace(
"fixture-desc", "script-type-desc"
)
require_lint_finding(
"script-type-override",
type_svg
+ f'<script data-diagram-controls type="application/json">{controller_body}</script>\n',
"script: only the canonical data-diagram-controls attribute is allowed",
directory,
)
inter_svg = VALID_SVG.replace("fixture-title", "inter-tight-title").replace(
"fixture-desc", "inter-tight-desc"
)
require_pass(
"inter-tight",
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;600&display=swap">\n'
'<style>text { font-family: "Inter Tight", sans-serif; }</style>\n'
+ inter_svg,
directory,
)
lookalike_svg = VALID_SVG.replace(
"fixture-title", "google-fonts-lookalike-title"
).replace("fixture-desc", "google-fonts-lookalike-desc")
require_lint_finding(
"google-fonts-lookalike",
'<link rel="stylesheet" href="https://fonts.googleapis.com.evil.test/css2?family=Inter+Tight">\n'
'<style>text { font-family: "Inter Tight", sans-serif; }</style>\n'
+ lookalike_svg,
"external-asset: external HTTP(S) <link> is not allowed",
directory,
)
bad_path_svg = VALID_SVG.replace(
"fixture-title", "google-fonts-bad-path-title"
).replace("fixture-desc", "google-fonts-bad-path-desc")
require_lint_finding(
"google-fonts-bad-path",
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2.evil?family=Inter+Tight">\n'
+ bad_path_svg,
"external-asset: external HTTP(S) <link> is not allowed",
directory,
)
protocol_relative_svg = VALID_SVG.replace(
"fixture-title", "protocol-relative-font-title"
).replace("fixture-desc", "protocol-relative-font-desc")
require_lint_finding(
"protocol-relative-font",
'<link rel="stylesheet" href="//fonts.googleapis.com/css2?family=Inter+Tight">\n'
+ protocol_relative_svg,
"external-asset: external HTTP(S) <link> is not allowed",
directory,
)
unquoted_remote_svg = VALID_SVG.replace(
"fixture-title", "unquoted-remote-title"
).replace("fixture-desc", "unquoted-remote-desc")
require_lint_finding(
"unquoted-remote-link",
'<link rel=stylesheet href=https://evil.example/x.css>\n'
+ unquoted_remote_svg,
"external-asset: external resource in <link> href is not allowed",
directory,
)
bypass_svg = VALID_SVG.replace("fixture-title", "resource-bypass-title").replace(
"fixture-desc", "resource-bypass-desc"
)
require_lint_finding(
"srcset-second-candidate",
'<img srcset="local.png 1x, https://evil.example/remote.png 2x">\n'
+ bypass_svg,
"external-asset: external resource in <img> srcset is not allowed",
directory,
)
require_lint_finding(
"protocol-relative-css-url",
'<style>.x { background: url(//evil.example/x.png); }</style>\n'
+ bypass_svg,
"external-asset: non-fragment CSS url() is not allowed",
directory,
)
require_lint_finding(
"event-handler",
'<div onclick="alert(1)"></div>\n' + bypass_svg,
"script: executable attribute onclick on <div> is not allowed",
directory,
)
require_lint_finding(
"javascript-url",
'<iframe src="javascript:alert(1)"></iframe>\n' + bypass_svg,
"script: executable attribute src on <iframe> is not allowed",
directory,
)
require_lint_finding(
"javascript-control-character",
'<iframe src="jav&#9;ascript:alert(1)"></iframe>\n' + bypass_svg,
"script: executable attribute src on <iframe> is not allowed",
directory,
)
require_lint_finding(
"base-href",
'<base href="https://evil.example/">\n' + bypass_svg,
"script: executable attribute href on <base> is not allowed",
directory,
)
require_lint_finding(
"iframe-srcdoc",
'<iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;"></iframe>\n'
+ bypass_svg,
"script: executable attribute srcdoc on <iframe> is not allowed",
directory,
)
require_lint_finding(
"css-escaped-scheme",
'<style>.x { background: url(https\\3a //evil.example/x); }</style>\n'
+ bypass_svg,
"external-asset: non-fragment CSS url() is not allowed",
directory,
)
arbitrary_font_svg = VALID_SVG.replace(
"fixture-title", "arbitrary-font-title"
).replace("fixture-desc", "arbitrary-font-desc")
require_lint_finding(
"arbitrary-font",
'<style>text { font-family: "Unapproved Sans", sans-serif; }</style>\n'
+ arbitrary_font_svg,
"font-family: unsupported font family: Unapproved Sans",
directory,
)
spec = importlib.util.spec_from_file_location("build_icons", BUILD_ICONS)
if spec is None or spec.loader is None:
@@ -186,6 +402,34 @@ def main() -> int:
ROOT / "skills/diagram-design/references/style-guide.md",
project / "skills/diagram-design/references/style-guide.md",
)
project_style = project / "skills/diagram-design/references/style-guide.md"
project_style.write_text(
project_style.read_text(encoding="utf-8").replace(
"| `title` | Instrument Serif |",
"| `title` | Inter Tight |",
1,
),
encoding="utf-8",
)
brand_font_path = project / "style-guide-font.html"
brand_font_path.write_text(
VALID_SVG.replace("fixture-title", "style-guide-font-title")
.replace("fixture-desc", "style-guide-font-desc")
.replace("</svg>", '<text style="font-family: Inter Tight;">Brand</text></svg>'),
encoding="utf-8",
)
brand_font_result = subprocess.run(
[sys.executable, str(project / "scripts/lint-skin.py"), str(brand_font_path)],
capture_output=True,
text=True,
check=False,
)
if brand_font_result.returncode != 0:
raise AssertionError(
"style-guide font: expected pass\n"
f"{brand_font_result.stdout}{brand_font_result.stderr}"
)
print("OK: exact customized style-guide typography family accepted")
(project / "scripts/lint-skin-baseline.txt").write_text(
"example-baseline.html\n", encoding="utf-8"
)