feat(skill): pre-draw checkpoint, docs-sync gate, packaged self-check, ADRs
- SKILL.md: confirm type/pattern/size/cuts with the user before rendering; checklist hooks for the checkpoint and the packaged self-check - scripts/verify-docs-sync.py: CI now fails when the frontmatter description loses a type's lexical hook, the gallery can't reach a shipped example, or the README tree names a missing file - skills/diagram-design/scripts/self_check.py: distilled output checker that ships with the skill so installed agents can verify their own diagrams; adversarial suite in scripts/test-self-check.py - assets/index.html: six new tabs (OAuth sequence + five single-variant examples with a data-single variant lock) so every shipped example is browsable - docs/adr/0001-0004: settled policies recorded (pinned controller, taxonomy freeze, reveal-only autoplay, byte cap + trigger-rich description) - README: It's-working-if section, new gates documented; CONTRIBUTING: gate table, ADR guidance, type-addition invariants; CI: two new steps
This commit is contained in:
@@ -122,6 +122,10 @@ Rules of thumb:
|
||||
|
||||
**Always load the chosen `references/type-*.md` before drawing.** When routed above, also load `semantic-patterns.md`; when animation is chosen, load `animation.md`.
|
||||
|
||||
### Confirm before drawing
|
||||
|
||||
Before rendering, state the plan in one short message: the chosen visual type (and semantic pattern, if routed), the size preset, and anything the complexity budget (§7) will force out. If the user is reachable, let them redirect before you draw; if not, proceed and note the assumptions beside the deliverable. Skip the pause only when the request already pins type, size, and content exactly.
|
||||
|
||||
---
|
||||
|
||||
## 4. Universal Anti-patterns
|
||||
@@ -425,6 +429,7 @@ Run before producing any diagram.
|
||||
|
||||
- [ ] If behavior matters, did I choose one semantic pattern before the visual type and load `semantic-patterns.md`?
|
||||
- [ ] Right visual type for the layout? (§3 visual-type guide)
|
||||
- [ ] Stated type, pattern, size preset, and planned cuts before drawing — confirmed, or assumptions noted? (§3)
|
||||
- [ ] Would a table / paragraph do the same job? (If yes — don't draw.)
|
||||
- [ ] Loaded the matching `references/type-*.md`?
|
||||
- [ ] If this is an import — format, size, detail level, and audience set? `viewBox` and type ramp match the size preset? (§11, [output-spec.md §6](references/output-spec.md))
|
||||
@@ -459,7 +464,8 @@ Run before producing any diagram.
|
||||
- [ ] No vertical `writing-mode` text?
|
||||
- [ ] `viewBox` expanded for the legend strip (~60px)?
|
||||
- [ ] Every font size, coord, width, height, gap divisible by 4?
|
||||
- [ ] If animated, does the complete static/no-JS frame work, does reduced motion hide/disable playback, and is the controller copied verbatim from `template-motion.html`? In this repository, run `python3 scripts/verify-motion.py path/to/generated.html` plus the skin linter; from an installed skill, manually check no-JS, reduced-motion, print, and static-query states because the repository verifiers are not packaged.
|
||||
- [ ] Ran the packaged self-check — `python3 <skill-dir>/scripts/self_check.py <file>` — clean? (Accessible-SVG contract, single-file safety, motion basics; ships with the skill.)
|
||||
- [ ] If animated, does the complete static/no-JS frame work, does reduced motion hide/disable playback, and is the controller copied verbatim from `template-motion.html`? In this repository, also run `python3 scripts/verify-motion.py path/to/generated.html` plus the skin linter; from an installed skill, manually check print and static-query states on top of the self-check.
|
||||
|
||||
**Typography:**
|
||||
|
||||
|
||||
@@ -114,6 +114,10 @@
|
||||
color: var(--ink);
|
||||
border-color: var(--rule-strong);
|
||||
}
|
||||
.tab:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.tab.active {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
@@ -278,6 +282,24 @@
|
||||
<button class="tab new" data-type="it-state">
|
||||
<span class="eyebrow">33</span>IT current-state
|
||||
</button>
|
||||
<button class="tab new" data-type="sequence-oauth">
|
||||
<span class="eyebrow">34</span>Sequence · OAuth
|
||||
</button>
|
||||
<button class="tab new" data-type="import-drawio" data-single>
|
||||
<span class="eyebrow">35</span>Import · draw.io
|
||||
</button>
|
||||
<button class="tab new" data-type="import-mermaid" data-single>
|
||||
<span class="eyebrow">36</span>Import · Mermaid
|
||||
</button>
|
||||
<button class="tab new" data-type="quadrant-consultant" data-single>
|
||||
<span class="eyebrow">37</span>Quadrant · consultant
|
||||
</button>
|
||||
<button class="tab new" data-type="loop-terminal" data-single>
|
||||
<span class="eyebrow">38</span>Loop · terminal
|
||||
</button>
|
||||
<button class="tab new" data-type="policy-trace-animated" data-single>
|
||||
<span class="eyebrow">39</span>Policy trace · animated
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -306,6 +328,14 @@
|
||||
const openRaw = document.getElementById("open-raw");
|
||||
|
||||
function update() {
|
||||
const activeType = document.querySelector("#type-tabs .tab.active");
|
||||
const single = activeType && activeType.hasAttribute("data-single");
|
||||
const variantTabs = document.querySelectorAll("#variant-tabs .tab");
|
||||
if (single) state.variant = "";
|
||||
variantTabs.forEach((tab) => {
|
||||
tab.disabled = single && tab.dataset.variant !== "";
|
||||
tab.classList.toggle("active", tab.dataset.variant === state.variant);
|
||||
});
|
||||
const src = `example-${state.type}${state.variant}.html`;
|
||||
iframe.src = src;
|
||||
openRaw.href = src;
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Self-check a generated diagram HTML file, with no third-party deps.
|
||||
|
||||
Ships inside the skill so an installed agent can verify its own output:
|
||||
|
||||
python3 <skill-dir>/scripts/self_check.py my-diagram.html
|
||||
|
||||
Checks the accessible-SVG contract, the single-file safety rules (no remote
|
||||
assets beyond the approved Google Fonts stylesheet, no executable attributes,
|
||||
no scripts other than the one canonical motion controller), and — when motion
|
||||
markup is present — the structural motion contract. This is a distilled
|
||||
subset of the repository gates (`lint-skin.py`, `verify-motion.py`), which
|
||||
remain the authority for contributions to the repository itself.
|
||||
"""
|
||||
|
||||
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 urlparse
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||||
MOTION_TEMPLATE = SKILL_DIR / "assets" / "template-motion.html"
|
||||
MODES = {"none", "reveal", "step", "loop"}
|
||||
ACTIONS = {"play", "pause", "replay", "prev", "next"}
|
||||
ASCII_DECIMAL_RE = re.compile(r"^[0-9]+$")
|
||||
REFERENCE_ATTRS = {"src", "href", "xlink:href", "poster", "srcset", "action", "formaction"}
|
||||
|
||||
|
||||
class DiagramParser(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.statuses_in_controls = 0
|
||||
self.scripts: list[dict[str, object]] = []
|
||||
self.styles: list[str] = []
|
||||
self.svgs: list[dict[str, object]] = []
|
||||
self.unsafe: list[str] = []
|
||||
self.references: list[tuple[str, str, str]] = []
|
||||
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._in_style = False
|
||||
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()
|
||||
normalized_attrs = [(key.casefold(), value or "") for key, value in attrs]
|
||||
data = {key: value for key, value in normalized_attrs}
|
||||
if tag in {"base", "embed", "object", "iframe"}:
|
||||
self.unsafe.append(f"<{tag}> is not allowed in a diagram file")
|
||||
for key, value in normalized_attrs:
|
||||
if key.startswith("on"):
|
||||
self.unsafe.append(f"executable attribute {key} on <{tag}>")
|
||||
if key == "srcdoc":
|
||||
self.unsafe.append(f"srcdoc attribute on <{tag}>")
|
||||
if key in REFERENCE_ATTRS:
|
||||
self.references.append((tag, data.get("rel", ""), value))
|
||||
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)
|
||||
if self._motion_root_depth is not None:
|
||||
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-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,
|
||||
"attr_names": [name for name, _value in normalized_attrs],
|
||||
"body": [],
|
||||
"closed": False,
|
||||
}
|
||||
self.scripts.append(self._current_script)
|
||||
if tag == "style":
|
||||
self._in_style = True
|
||||
self._element_stack.append(tag)
|
||||
if tag == "svg" and self._svg_depth == 0:
|
||||
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 self._svg_depth == 2 and 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 tag == "style":
|
||||
self._in_style = False
|
||||
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
|
||||
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._in_style:
|
||||
self.styles.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) -> DiagramParser:
|
||||
parser = DiagramParser()
|
||||
parser.feed(source)
|
||||
parser.close()
|
||||
return parser
|
||||
|
||||
|
||||
def is_approved_google_fonts_stylesheet(value: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return (
|
||||
parsed.scheme == "https"
|
||||
and parsed.hostname is not None
|
||||
and parsed.hostname.casefold() == "fonts.googleapis.com"
|
||||
and parsed.port is None
|
||||
and parsed.path == "/css2"
|
||||
and not parsed.fragment
|
||||
)
|
||||
|
||||
|
||||
def reference_error(tag: str, rel: str, value: str) -> str | None:
|
||||
stripped = value.strip()
|
||||
lowered = stripped.casefold()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
return None
|
||||
if lowered.startswith("javascript:") or lowered.startswith("data:text/html"):
|
||||
return f"executable URL on <{tag}>: {stripped[:80]}"
|
||||
remote = lowered.startswith(("http://", "https://", "//")) or (
|
||||
":" in stripped.split("/", 1)[0] and not lowered.startswith("data:")
|
||||
)
|
||||
if not remote:
|
||||
if lowered.startswith("data:") and not lowered.startswith("data:image/"):
|
||||
return f"non-image data URL on <{tag}>: {stripped[:80]}"
|
||||
return None
|
||||
if tag == "link" and "stylesheet" in rel.casefold().split():
|
||||
if is_approved_google_fonts_stylesheet(stripped):
|
||||
return None
|
||||
return f"remote stylesheet is not the approved Google Fonts /css2 URL: {stripped[:80]}"
|
||||
return f"remote reference on <{tag}>: {stripped[:80]}"
|
||||
|
||||
|
||||
def canonical_controller() -> str:
|
||||
if not MOTION_TEMPLATE.is_file():
|
||||
raise RuntimeError(
|
||||
f"cannot find the canonical controller at {MOTION_TEMPLATE}; "
|
||||
"run self_check.py from its shipped location inside the skill"
|
||||
)
|
||||
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 check_svgs(parser: DiagramParser, errors: list[str]) -> None:
|
||||
checkable = [
|
||||
svg
|
||||
for svg in parser.svgs
|
||||
if isinstance(svg["attrs"], dict)
|
||||
and str(svg["attrs"].get("aria-hidden", "")).casefold() != "true"
|
||||
]
|
||||
if not checkable:
|
||||
errors.append("diagram file needs at least one accessible (non-aria-hidden) SVG")
|
||||
for number, svg in enumerate(checkable, 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", {})
|
||||
assert isinstance(title_attrs, dict) and isinstance(desc_attrs, dict)
|
||||
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")
|
||||
title_id = title_attrs.get("id", "")
|
||||
desc_id = desc_attrs.get("id", "")
|
||||
if title_id in {"", "title"} or desc_id in {"", "desc"}:
|
||||
errors.append(f"svg {number} title/desc IDs must be diagram-prefixed, never bare")
|
||||
if labelled != [title_id, desc_id]:
|
||||
errors.append(f"svg {number} aria-labelledby must name title then desc")
|
||||
|
||||
|
||||
def check_scripts(parser: DiagramParser, errors: list[str]) -> None:
|
||||
if not parser.scripts:
|
||||
return
|
||||
if len(parser.scripts) > 1:
|
||||
errors.append(f"at most one script is allowed; 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")
|
||||
continue
|
||||
try:
|
||||
if normalized_controller("".join(body)) != canonical_controller():
|
||||
errors.append(f"script {number} must exactly match the controller in template-motion.html")
|
||||
except RuntimeError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
|
||||
def check_motion(parser: DiagramParser, source: str, errors: list[str]) -> None:
|
||||
has_motion_markup = bool(parser.roots or parser.items or parser.scripts)
|
||||
if not has_motion_markup:
|
||||
return
|
||||
if len(parser.roots) != 1:
|
||||
errors.append(f"expected exactly one data-motion-root; found {len(parser.roots)}")
|
||||
return
|
||||
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}")
|
||||
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 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}")
|
||||
|
||||
if len(parser.items) > 12:
|
||||
errors.append(f"motion item budget is 12; found {len(parser.items)}")
|
||||
semantic_steps: list[int] = []
|
||||
for index, item in enumerate(parser.items, 1):
|
||||
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)
|
||||
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")
|
||||
elif 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; the 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: n for step, n in Counter(semantic_steps).items() if n > 2}
|
||||
if crowded:
|
||||
errors.append(f"no more than two semantic items may share a step; found {crowded}")
|
||||
|
||||
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")
|
||||
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 parser.statuses_in_controls:
|
||||
errors.append("motion status must sit outside data-motion-controls")
|
||||
if not parser.scripts:
|
||||
errors.append("controlled mode needs the scoped control script")
|
||||
|
||||
style_source = "".join(parser.styles)
|
||||
if parser.scripts:
|
||||
if re.search(r"prefers-reduced-motion\s*:\s*reduce", style_source, re.IGNORECASE) is None:
|
||||
errors.append("missing reduced-motion CSS fallback (prefers-reduced-motion)")
|
||||
if re.search(r"@media\s+print\b", style_source, re.IGNORECASE) is None:
|
||||
errors.append("missing print CSS fallback (@media print)")
|
||||
if "<noscript" not in source.casefold():
|
||||
errors.append("motion file needs a <noscript> explanation of the complete static frame")
|
||||
|
||||
|
||||
def verify(path: Path) -> list[str]:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
parser = parsed_document(source)
|
||||
errors: list[str] = []
|
||||
errors.extend(parser.unsafe)
|
||||
for tag, rel, value in parser.references:
|
||||
finding = reference_error(tag, rel, value)
|
||||
if finding:
|
||||
errors.append(finding)
|
||||
check_svgs(parser, errors)
|
||||
check_scripts(parser, errors)
|
||||
check_motion(parser, source, errors)
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
argument_parser = argparse.ArgumentParser(description=__doc__)
|
||||
argument_parser.add_argument("files", nargs="+", type=Path)
|
||||
args = argument_parser.parse_args()
|
||||
failed = False
|
||||
for path in args.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())
|
||||
Reference in New Issue
Block a user