@taloswork/ose-shared 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/package.json +3 -3
  2. package/skills/cozy-static-svg/SKILL.md +76 -0
  3. package/skills/cozy-static-svg/references/approved-preview.png +0 -0
  4. package/skills/cozy-static-svg/references/cross-platform.md +73 -0
  5. package/skills/cozy-static-svg/references/moon_keeps_watch.svg +229 -0
  6. package/skills/cozy-static-svg/references/style-anchor.md +271 -0
  7. package/skills/cozy-static-svg/scripts/artkit.py +321 -0
  8. package/skills/cozy-static-svg/scripts/to_png.py +333 -0
  9. package/skills/diagram-design/SKILL.md +568 -0
  10. package/skills/diagram-design/assets/example-architecture-dark.html +182 -0
  11. package/skills/diagram-design/assets/example-architecture-full.html +194 -0
  12. package/skills/diagram-design/assets/example-architecture.html +183 -0
  13. package/skills/diagram-design/assets/example-bar-dark.html +129 -0
  14. package/skills/diagram-design/assets/example-bar-full.html +105 -0
  15. package/skills/diagram-design/assets/example-bar.html +129 -0
  16. package/skills/diagram-design/assets/example-data-flow-dark.html +143 -0
  17. package/skills/diagram-design/assets/example-data-flow-full.html +185 -0
  18. package/skills/diagram-design/assets/example-data-flow.html +143 -0
  19. package/skills/diagram-design/assets/example-datalake-dark.html +243 -0
  20. package/skills/diagram-design/assets/example-datalake-full.html +248 -0
  21. package/skills/diagram-design/assets/example-datalake.html +269 -0
  22. package/skills/diagram-design/assets/example-dp-integration-dark.html +80 -0
  23. package/skills/diagram-design/assets/example-dp-integration-full.html +90 -0
  24. package/skills/diagram-design/assets/example-dp-integration.html +80 -0
  25. package/skills/diagram-design/assets/example-dp-security-matrix-dark.html +31 -0
  26. package/skills/diagram-design/assets/example-dp-security-matrix-full.html +32 -0
  27. package/skills/diagram-design/assets/example-dp-security-matrix.html +88 -0
  28. package/skills/diagram-design/assets/example-er-dark.html +202 -0
  29. package/skills/diagram-design/assets/example-er-full.html +205 -0
  30. package/skills/diagram-design/assets/example-er.html +202 -0
  31. package/skills/diagram-design/assets/example-flowchart-dark.html +156 -0
  32. package/skills/diagram-design/assets/example-flowchart-full.html +159 -0
  33. package/skills/diagram-design/assets/example-flowchart.html +156 -0
  34. package/skills/diagram-design/assets/example-gantt-dark.html +137 -0
  35. package/skills/diagram-design/assets/example-gantt-full.html +127 -0
  36. package/skills/diagram-design/assets/example-gantt.html +137 -0
  37. package/skills/diagram-design/assets/example-high-level-dark.html +251 -0
  38. package/skills/diagram-design/assets/example-high-level-full.html +247 -0
  39. package/skills/diagram-design/assets/example-high-level-vertical-dark.html +274 -0
  40. package/skills/diagram-design/assets/example-high-level-vertical-full.html +252 -0
  41. package/skills/diagram-design/assets/example-high-level-vertical.html +325 -0
  42. package/skills/diagram-design/assets/example-high-level.html +301 -0
  43. package/skills/diagram-design/assets/example-import-drawio.html +185 -0
  44. package/skills/diagram-design/assets/example-import-mermaid.html +70 -0
  45. package/skills/diagram-design/assets/example-it-state-dark.html +90 -0
  46. package/skills/diagram-design/assets/example-it-state-full.html +69 -0
  47. package/skills/diagram-design/assets/example-it-state.html +128 -0
  48. package/skills/diagram-design/assets/example-layers-dark.html +123 -0
  49. package/skills/diagram-design/assets/example-layers-full.html +126 -0
  50. package/skills/diagram-design/assets/example-layers.html +123 -0
  51. package/skills/diagram-design/assets/example-line-dark.html +111 -0
  52. package/skills/diagram-design/assets/example-line-full.html +105 -0
  53. package/skills/diagram-design/assets/example-line.html +111 -0
  54. package/skills/diagram-design/assets/example-loop-dark.html +100 -0
  55. package/skills/diagram-design/assets/example-loop-full.html +137 -0
  56. package/skills/diagram-design/assets/example-loop-terminal.html +347 -0
  57. package/skills/diagram-design/assets/example-loop.html +100 -0
  58. package/skills/diagram-design/assets/example-medallion-dark.html +159 -0
  59. package/skills/diagram-design/assets/example-medallion-full.html +201 -0
  60. package/skills/diagram-design/assets/example-medallion.html +159 -0
  61. package/skills/diagram-design/assets/example-nested-dark.html +132 -0
  62. package/skills/diagram-design/assets/example-nested-full.html +135 -0
  63. package/skills/diagram-design/assets/example-nested.html +136 -0
  64. package/skills/diagram-design/assets/example-org-chart-dark.html +45 -0
  65. package/skills/diagram-design/assets/example-org-chart-full.html +45 -0
  66. package/skills/diagram-design/assets/example-org-chart.html +45 -0
  67. package/skills/diagram-design/assets/example-paved-road-animated.html +546 -0
  68. package/skills/diagram-design/assets/example-policy-trace-animated.html +490 -0
  69. package/skills/diagram-design/assets/example-process-dark.html +64 -0
  70. package/skills/diagram-design/assets/example-process-full.html +74 -0
  71. package/skills/diagram-design/assets/example-process.html +64 -0
  72. package/skills/diagram-design/assets/example-pyramid-dark.html +119 -0
  73. package/skills/diagram-design/assets/example-pyramid-full.html +122 -0
  74. package/skills/diagram-design/assets/example-pyramid.html +119 -0
  75. package/skills/diagram-design/assets/example-quadrant-consultant.html +166 -0
  76. package/skills/diagram-design/assets/example-quadrant-dark.html +135 -0
  77. package/skills/diagram-design/assets/example-quadrant-full.html +138 -0
  78. package/skills/diagram-design/assets/example-quadrant.html +135 -0
  79. package/skills/diagram-design/assets/example-queue-animated.html +577 -0
  80. package/skills/diagram-design/assets/example-radar-dark.html +139 -0
  81. package/skills/diagram-design/assets/example-radar-full.html +135 -0
  82. package/skills/diagram-design/assets/example-radar.html +139 -0
  83. package/skills/diagram-design/assets/example-scatter-dark.html +141 -0
  84. package/skills/diagram-design/assets/example-scatter-full.html +116 -0
  85. package/skills/diagram-design/assets/example-scatter.html +141 -0
  86. package/skills/diagram-design/assets/example-sequence-dark.html +222 -0
  87. package/skills/diagram-design/assets/example-sequence-full.html +388 -0
  88. package/skills/diagram-design/assets/example-sequence-oauth-dark.html +188 -0
  89. package/skills/diagram-design/assets/example-sequence-oauth-full.html +350 -0
  90. package/skills/diagram-design/assets/example-sequence-oauth.html +227 -0
  91. package/skills/diagram-design/assets/example-sequence.html +222 -0
  92. package/skills/diagram-design/assets/example-state-dark.html +147 -0
  93. package/skills/diagram-design/assets/example-state-full.html +150 -0
  94. package/skills/diagram-design/assets/example-state.html +147 -0
  95. package/skills/diagram-design/assets/example-swimlane-dark.html +172 -0
  96. package/skills/diagram-design/assets/example-swimlane-full.html +175 -0
  97. package/skills/diagram-design/assets/example-swimlane.html +172 -0
  98. package/skills/diagram-design/assets/example-timeline-dark.html +135 -0
  99. package/skills/diagram-design/assets/example-timeline-full.html +138 -0
  100. package/skills/diagram-design/assets/example-timeline.html +135 -0
  101. package/skills/diagram-design/assets/example-tree-dark.html +173 -0
  102. package/skills/diagram-design/assets/example-tree-full.html +176 -0
  103. package/skills/diagram-design/assets/example-tree.html +173 -0
  104. package/skills/diagram-design/assets/example-venn-dark.html +132 -0
  105. package/skills/diagram-design/assets/example-venn-full.html +135 -0
  106. package/skills/diagram-design/assets/example-venn.html +110 -0
  107. package/skills/diagram-design/assets/icons.html +230 -0
  108. package/skills/diagram-design/assets/index.html +369 -0
  109. package/skills/diagram-design/assets/template-dark.html +85 -0
  110. package/skills/diagram-design/assets/template-full.html +413 -0
  111. package/skills/diagram-design/assets/template-motion.html +434 -0
  112. package/skills/diagram-design/assets/template-terminal.html +173 -0
  113. package/skills/diagram-design/assets/template.html +86 -0
  114. package/skills/diagram-design/references/animation.md +141 -0
  115. package/skills/diagram-design/references/export.md +136 -0
  116. package/skills/diagram-design/references/import-drawio.md +171 -0
  117. package/skills/diagram-design/references/import-mermaid.md +126 -0
  118. package/skills/diagram-design/references/onboarding.md +297 -0
  119. package/skills/diagram-design/references/output-spec.md +181 -0
  120. package/skills/diagram-design/references/primitive-annotation.md +36 -0
  121. package/skills/diagram-design/references/primitive-icons.md +827 -0
  122. package/skills/diagram-design/references/primitive-sketchy.md +43 -0
  123. package/skills/diagram-design/references/primitive-terminal.md +76 -0
  124. package/skills/diagram-design/references/semantic-patterns.md +122 -0
  125. package/skills/diagram-design/references/style-guide.md +139 -0
  126. package/skills/diagram-design/references/type-architecture.md +78 -0
  127. package/skills/diagram-design/references/type-bar.md +48 -0
  128. package/skills/diagram-design/references/type-data-flow.md +374 -0
  129. package/skills/diagram-design/references/type-dp-integration.md +410 -0
  130. package/skills/diagram-design/references/type-dp-security-matrix.md +379 -0
  131. package/skills/diagram-design/references/type-er.md +23 -0
  132. package/skills/diagram-design/references/type-flowchart.md +23 -0
  133. package/skills/diagram-design/references/type-gantt.md +45 -0
  134. package/skills/diagram-design/references/type-high-level.md +458 -0
  135. package/skills/diagram-design/references/type-it-state.md +470 -0
  136. package/skills/diagram-design/references/type-layers.md +26 -0
  137. package/skills/diagram-design/references/type-line.md +44 -0
  138. package/skills/diagram-design/references/type-loop.md +223 -0
  139. package/skills/diagram-design/references/type-medallion.md +356 -0
  140. package/skills/diagram-design/references/type-nested.md +22 -0
  141. package/skills/diagram-design/references/type-org-chart.md +44 -0
  142. package/skills/diagram-design/references/type-process.md +495 -0
  143. package/skills/diagram-design/references/type-pyramid.md +33 -0
  144. package/skills/diagram-design/references/type-quadrant.md +81 -0
  145. package/skills/diagram-design/references/type-radar.md +80 -0
  146. package/skills/diagram-design/references/type-scatter.md +39 -0
  147. package/skills/diagram-design/references/type-sequence.md +130 -0
  148. package/skills/diagram-design/references/type-state.md +21 -0
  149. package/skills/diagram-design/references/type-swimlane.md +20 -0
  150. package/skills/diagram-design/references/type-timeline.md +20 -0
  151. package/skills/diagram-design/references/type-tree.md +24 -0
  152. package/skills/diagram-design/references/type-venn.md +26 -0
  153. package/skills/diagram-design/scripts/drawio_extract.py +856 -0
  154. package/skills/diagram-design/scripts/mermaid_extract.py +1285 -0
  155. package/skills/diagram-design/scripts/self_check.py +389 -0
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env python3
2
+ """Self-check a generated diagram HTML file, with no third-party deps.
3
+
4
+ Ships inside the skill so an installed agent can verify its own output:
5
+
6
+ python3 <skill-dir>/scripts/self_check.py my-diagram.html
7
+
8
+ Checks the accessible-SVG contract, the single-file safety rules (no remote
9
+ assets beyond the approved Google Fonts stylesheet, no executable attributes,
10
+ no scripts other than the one canonical motion controller), and — when motion
11
+ markup is present — the structural motion contract. This is a distilled
12
+ subset of the repository gates (`lint-skin.py`, `verify-motion.py`), which
13
+ remain the authority for contributions to the repository itself.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import re
20
+ import sys
21
+ from collections import Counter
22
+ from html.parser import HTMLParser
23
+ from pathlib import Path
24
+ from urllib.parse import urlparse
25
+
26
+ SKILL_DIR = Path(__file__).resolve().parent.parent
27
+ MOTION_TEMPLATE = SKILL_DIR / "assets" / "template-motion.html"
28
+ MODES = {"none", "reveal", "step", "loop"}
29
+ ACTIONS = {"play", "pause", "replay", "prev", "next"}
30
+ ASCII_DECIMAL_RE = re.compile(r"^[0-9]+$")
31
+ REFERENCE_ATTRS = {"src", "href", "xlink:href", "poster", "srcset", "action", "formaction"}
32
+
33
+
34
+ class DiagramParser(HTMLParser):
35
+ def __init__(self) -> None:
36
+ super().__init__(convert_charrefs=True)
37
+ self.roots: list[dict[str, str]] = []
38
+ self.items: list[dict[str, str]] = []
39
+ self.actions: set[str] = set()
40
+ self.controls = 0
41
+ self.statuses: list[dict[str, str]] = []
42
+ self.statuses_in_controls = 0
43
+ self.scripts: list[dict[str, object]] = []
44
+ self.styles: list[str] = []
45
+ self.svgs: list[dict[str, object]] = []
46
+ self.unsafe: list[str] = []
47
+ self.references: list[tuple[str, str, str]] = []
48
+ self._svg_depth = 0
49
+ self._current_svg: dict[str, object] | None = None
50
+ self._capture: str | None = None
51
+ self._current_script: dict[str, object] | None = None
52
+ self._in_style = False
53
+ self._element_stack: list[str] = []
54
+ self._motion_root_depth: int | None = None
55
+ self._controls_depth: int | None = None
56
+
57
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
58
+ tag = tag.casefold()
59
+ normalized_attrs = [(key.casefold(), value or "") for key, value in attrs]
60
+ data = {key: value for key, value in normalized_attrs}
61
+ if tag in {"base", "embed", "object", "iframe"}:
62
+ self.unsafe.append(f"<{tag}> is not allowed in a diagram file")
63
+ for key, value in normalized_attrs:
64
+ if key.startswith("on"):
65
+ self.unsafe.append(f"executable attribute {key} on <{tag}>")
66
+ if key == "srcdoc":
67
+ self.unsafe.append(f"srcdoc attribute on <{tag}>")
68
+ if key in REFERENCE_ATTRS:
69
+ self.references.append((tag, data.get("rel", ""), value))
70
+ if "data-motion-root" in data:
71
+ self.roots.append(data)
72
+ if self._motion_root_depth is None:
73
+ self._motion_root_depth = len(self._element_stack)
74
+ if self._motion_root_depth is not None:
75
+ if "data-motion-item" in data:
76
+ self.items.append(data)
77
+ if "data-motion-action" in data:
78
+ self.actions.add(data["data-motion-action"])
79
+ if "data-motion-controls" in data:
80
+ self.controls += 1
81
+ if self._controls_depth is None:
82
+ self._controls_depth = len(self._element_stack)
83
+ if "data-motion-status" in data:
84
+ self.statuses.append(data)
85
+ if self._controls_depth is not None:
86
+ self.statuses_in_controls += 1
87
+ if tag == "script":
88
+ self._current_script = {
89
+ "attrs": data,
90
+ "attr_names": [name for name, _value in normalized_attrs],
91
+ "body": [],
92
+ "closed": False,
93
+ }
94
+ self.scripts.append(self._current_script)
95
+ if tag == "style":
96
+ self._in_style = True
97
+ self._element_stack.append(tag)
98
+ if tag == "svg" and self._svg_depth == 0:
99
+ self._svg_depth = 1
100
+ self._current_svg = {"attrs": data, "first": None, "title": {}, "desc": {}}
101
+ self.svgs.append(self._current_svg)
102
+ return
103
+ if self._svg_depth:
104
+ self._svg_depth += 1
105
+ assert self._current_svg is not None
106
+ if self._svg_depth == 2 and self._current_svg["first"] is None:
107
+ self._current_svg["first"] = tag
108
+ if self._svg_depth == 2 and tag in {"title", "desc"}:
109
+ self._current_svg[tag] = {"attrs": data, "text": ""}
110
+ self._capture = tag
111
+
112
+ def handle_endtag(self, tag: str) -> None:
113
+ tag = tag.casefold()
114
+ if tag == "script" and self._current_script is not None:
115
+ self._current_script["closed"] = True
116
+ self._current_script = None
117
+ if tag == "style":
118
+ self._in_style = False
119
+ if self._svg_depth:
120
+ if tag in {"title", "desc"}:
121
+ self._capture = None
122
+ self._svg_depth -= 1
123
+ if self._svg_depth == 0:
124
+ self._current_svg = None
125
+ for index in range(len(self._element_stack) - 1, -1, -1):
126
+ if self._element_stack[index] == tag:
127
+ del self._element_stack[index:]
128
+ break
129
+ if (
130
+ self._motion_root_depth is not None
131
+ and len(self._element_stack) <= self._motion_root_depth
132
+ ):
133
+ self._motion_root_depth = None
134
+ if (
135
+ self._controls_depth is not None
136
+ and len(self._element_stack) <= self._controls_depth
137
+ ):
138
+ self._controls_depth = None
139
+
140
+ def handle_data(self, data: str) -> None:
141
+ if self._current_script is not None:
142
+ body = self._current_script["body"]
143
+ assert isinstance(body, list)
144
+ body.append(data)
145
+ if self._in_style:
146
+ self.styles.append(data)
147
+ if self._capture and self._current_svg:
148
+ node = self._current_svg[self._capture]
149
+ assert isinstance(node, dict)
150
+ node["text"] = str(node.get("text", "")) + data
151
+
152
+
153
+ def normalized_controller(body: str) -> str:
154
+ return body.replace("\r\n", "\n").replace("\r", "\n").strip()
155
+
156
+
157
+ def parsed_document(source: str) -> DiagramParser:
158
+ parser = DiagramParser()
159
+ parser.feed(source)
160
+ parser.close()
161
+ return parser
162
+
163
+
164
+ def is_approved_google_fonts_stylesheet(value: str) -> bool:
165
+ try:
166
+ parsed = urlparse(value)
167
+ except ValueError:
168
+ return False
169
+ return (
170
+ parsed.scheme == "https"
171
+ and parsed.hostname is not None
172
+ and parsed.hostname.casefold() == "fonts.googleapis.com"
173
+ and parsed.port is None
174
+ and parsed.path == "/css2"
175
+ and not parsed.fragment
176
+ )
177
+
178
+
179
+ def reference_error(tag: str, rel: str, value: str) -> str | None:
180
+ stripped = value.strip()
181
+ lowered = stripped.casefold()
182
+ if not stripped or stripped.startswith("#"):
183
+ return None
184
+ if lowered.startswith("javascript:") or lowered.startswith("data:text/html"):
185
+ return f"executable URL on <{tag}>: {stripped[:80]}"
186
+ remote = lowered.startswith(("http://", "https://", "//")) or (
187
+ ":" in stripped.split("/", 1)[0] and not lowered.startswith("data:")
188
+ )
189
+ if not remote:
190
+ if lowered.startswith("data:") and not lowered.startswith("data:image/"):
191
+ return f"non-image data URL on <{tag}>: {stripped[:80]}"
192
+ return None
193
+ if tag == "link" and "stylesheet" in rel.casefold().split():
194
+ if is_approved_google_fonts_stylesheet(stripped):
195
+ return None
196
+ return f"remote stylesheet is not the approved Google Fonts /css2 URL: {stripped[:80]}"
197
+ return f"remote reference on <{tag}>: {stripped[:80]}"
198
+
199
+
200
+ def canonical_controller() -> str:
201
+ if not MOTION_TEMPLATE.is_file():
202
+ raise RuntimeError(
203
+ f"cannot find the canonical controller at {MOTION_TEMPLATE}; "
204
+ "run self_check.py from its shipped location inside the skill"
205
+ )
206
+ parser = parsed_document(MOTION_TEMPLATE.read_text(encoding="utf-8"))
207
+ if len(parser.scripts) != 1 or not parser.scripts[0]["closed"]:
208
+ raise RuntimeError("template-motion.html must contain one closed controller")
209
+ body = parser.scripts[0]["body"]
210
+ assert isinstance(body, list)
211
+ return normalized_controller("".join(body))
212
+
213
+
214
+ def check_svgs(parser: DiagramParser, errors: list[str]) -> None:
215
+ checkable = [
216
+ svg
217
+ for svg in parser.svgs
218
+ if isinstance(svg["attrs"], dict)
219
+ and str(svg["attrs"].get("aria-hidden", "")).casefold() != "true"
220
+ ]
221
+ if not checkable:
222
+ errors.append("diagram file needs at least one accessible (non-aria-hidden) SVG")
223
+ for number, svg in enumerate(checkable, 1):
224
+ attrs = svg["attrs"]
225
+ assert isinstance(attrs, dict)
226
+ if attrs.get("role") != "img":
227
+ errors.append(f"svg {number} needs role=img")
228
+ labelled = attrs.get("aria-labelledby", "").split()
229
+ title = svg["title"]
230
+ desc = svg["desc"]
231
+ assert isinstance(title, dict) and isinstance(desc, dict)
232
+ title_attrs = title.get("attrs", {})
233
+ desc_attrs = desc.get("attrs", {})
234
+ assert isinstance(title_attrs, dict) and isinstance(desc_attrs, dict)
235
+ if svg["first"] != "title":
236
+ errors.append(f"svg {number} title must be its first child")
237
+ if not str(title.get("text", "")).strip() or not str(desc.get("text", "")).strip():
238
+ errors.append(f"svg {number} needs non-empty title and desc")
239
+ title_id = title_attrs.get("id", "")
240
+ desc_id = desc_attrs.get("id", "")
241
+ if title_id in {"", "title"} or desc_id in {"", "desc"}:
242
+ errors.append(f"svg {number} title/desc IDs must be diagram-prefixed, never bare")
243
+ if labelled != [title_id, desc_id]:
244
+ errors.append(f"svg {number} aria-labelledby must name title then desc")
245
+
246
+
247
+ def check_scripts(parser: DiagramParser, errors: list[str]) -> None:
248
+ if not parser.scripts:
249
+ return
250
+ if len(parser.scripts) > 1:
251
+ errors.append(f"at most one script is allowed; found {len(parser.scripts)}")
252
+ for number, script in enumerate(parser.scripts, 1):
253
+ attrs = script["attrs"]
254
+ attr_names = script["attr_names"]
255
+ body = script["body"]
256
+ assert isinstance(attrs, dict) and isinstance(attr_names, list) and isinstance(body, list)
257
+ if not script["closed"]:
258
+ errors.append(f"script {number} must have a closing script tag")
259
+ if attr_names != ["data-diagram-controls"] or attrs.get("data-diagram-controls") != "":
260
+ errors.append(f"script {number} must carry only the canonical data-diagram-controls attribute")
261
+ continue
262
+ try:
263
+ if normalized_controller("".join(body)) != canonical_controller():
264
+ errors.append(f"script {number} must exactly match the controller in template-motion.html")
265
+ except RuntimeError as exc:
266
+ errors.append(str(exc))
267
+
268
+
269
+ def check_motion(parser: DiagramParser, source: str, errors: list[str]) -> None:
270
+ has_motion_markup = bool(parser.roots or parser.items or parser.scripts)
271
+ if not has_motion_markup:
272
+ return
273
+ if len(parser.roots) != 1:
274
+ errors.append(f"expected exactly one data-motion-root; found {len(parser.roots)}")
275
+ return
276
+ root = parser.roots[0]
277
+ mode = root.get("data-motion-mode", "")
278
+ if mode not in MODES:
279
+ errors.append(f"data-motion-mode must be one of {sorted(MODES)}; got {mode!r}")
280
+ raw_count = root.get("data-step-count", "")
281
+ if not ASCII_DECIMAL_RE.fullmatch(raw_count):
282
+ count = -1
283
+ errors.append("data-step-count must be an ASCII decimal integer")
284
+ else:
285
+ count = int(raw_count)
286
+ minimum_count = 0 if mode == "none" else 1
287
+ if count < minimum_count or count > 8:
288
+ errors.append(f"semantic step count must be {minimum_count}..8; got {count}")
289
+
290
+ if len(parser.items) > 12:
291
+ errors.append(f"motion item budget is 12; found {len(parser.items)}")
292
+ semantic_steps: list[int] = []
293
+ for index, item in enumerate(parser.items, 1):
294
+ raw_step = item.get("data-step", "")
295
+ if not ASCII_DECIMAL_RE.fullmatch(raw_step):
296
+ errors.append(f"motion item {index} has a non-ASCII-decimal data-step")
297
+ continue
298
+ step = int(raw_step)
299
+ decorative = "data-motion-decorative" in item
300
+ if not decorative:
301
+ semantic_steps.append(step)
302
+ if not item.get("aria-label", "").strip():
303
+ errors.append(f"semantic motion item {index} needs a non-color aria-label")
304
+ elif item.get("aria-hidden") != "true" or item.get("focusable") != "false":
305
+ errors.append(f"decorative motion item {index} needs aria-hidden=true and focusable=false")
306
+ inline = item.get("style", "").replace(" ", "").lower()
307
+ if any(token in inline for token in ("display:none", "visibility:hidden", "opacity:0")):
308
+ errors.append(f"motion item {index} is hidden in source; the fallback must be visible")
309
+
310
+ expected = set(range(1, count + 1)) if count > 0 else set()
311
+ if set(semantic_steps) != expected:
312
+ errors.append(f"semantic steps must be contiguous 1..{count}; found {sorted(set(semantic_steps))}")
313
+ crowded = {step: n for step, n in Counter(semantic_steps).items() if n > 2}
314
+ if crowded:
315
+ errors.append(f"no more than two semantic items may share a step; found {crowded}")
316
+
317
+ if mode in {"none", "loop"} and parser.scripts:
318
+ errors.append(f"{mode} mode must be script-free")
319
+ if mode in {"none", "loop"} and (parser.controls or parser.actions or parser.statuses):
320
+ errors.append(f"{mode} mode must not expose playback controls or live status")
321
+ controlled = mode == "step" or (mode == "reveal" and bool(parser.scripts))
322
+ if controlled:
323
+ if parser.controls != 1:
324
+ errors.append(f"controlled mode needs one in-root control group; found {parser.controls}")
325
+ missing = ACTIONS - parser.actions
326
+ if missing:
327
+ errors.append(f"controlled mode is missing actions: {', '.join(sorted(missing))}")
328
+ if not parser.statuses:
329
+ errors.append("controlled mode needs data-motion-status")
330
+ else:
331
+ status = parser.statuses[0]
332
+ if (
333
+ status.get("role") != "status"
334
+ or status.get("aria-live") != "polite"
335
+ or status.get("aria-atomic") != "true"
336
+ ):
337
+ errors.append("motion status needs role=status, aria-live=polite, aria-atomic=true")
338
+ if parser.statuses_in_controls:
339
+ errors.append("motion status must sit outside data-motion-controls")
340
+ if not parser.scripts:
341
+ errors.append("controlled mode needs the scoped control script")
342
+
343
+ style_source = "".join(parser.styles)
344
+ if parser.scripts:
345
+ if re.search(r"prefers-reduced-motion\s*:\s*reduce", style_source, re.IGNORECASE) is None:
346
+ errors.append("missing reduced-motion CSS fallback (prefers-reduced-motion)")
347
+ if re.search(r"@media\s+print\b", style_source, re.IGNORECASE) is None:
348
+ errors.append("missing print CSS fallback (@media print)")
349
+ if "<noscript" not in source.casefold():
350
+ errors.append("motion file needs a <noscript> explanation of the complete static frame")
351
+
352
+
353
+ def verify(path: Path) -> list[str]:
354
+ source = path.read_text(encoding="utf-8")
355
+ parser = parsed_document(source)
356
+ errors: list[str] = []
357
+ errors.extend(parser.unsafe)
358
+ for tag, rel, value in parser.references:
359
+ finding = reference_error(tag, rel, value)
360
+ if finding:
361
+ errors.append(finding)
362
+ check_svgs(parser, errors)
363
+ check_scripts(parser, errors)
364
+ check_motion(parser, source, errors)
365
+ return errors
366
+
367
+
368
+ def main() -> int:
369
+ argument_parser = argparse.ArgumentParser(description=__doc__)
370
+ argument_parser.add_argument("files", nargs="+", type=Path)
371
+ args = argument_parser.parse_args()
372
+ failed = False
373
+ for path in args.files:
374
+ try:
375
+ errors = verify(path)
376
+ except (OSError, UnicodeError) as exc:
377
+ errors = [str(exc)]
378
+ if errors:
379
+ failed = True
380
+ print(f"FAIL {path}")
381
+ for error in errors:
382
+ print(f" - {error}")
383
+ else:
384
+ print(f"OK {path}")
385
+ return 1 if failed else 0
386
+
387
+
388
+ if __name__ == "__main__":
389
+ sys.exit(main())