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:
Cathryn Lavery
2026-08-12 17:00:08 -07:00
parent 8a6623ad88
commit dbc4abd965
12 changed files with 804 additions and 5 deletions
+12
View File
@@ -104,6 +104,16 @@ jobs:
id: motion
run: python scripts/test-verify-motion.py
- name: Verify docs and routing sync
if: always()
id: docs_sync
run: python scripts/verify-docs-sync.py
- name: Verify packaged self-check
if: always()
id: self_check
run: python scripts/test-self-check.py
- name: Verify generated icon assets
if: always()
id: icons
@@ -134,4 +144,6 @@ jobs:
echo "| Draw.io Import Extractor | ${{ steps.drawio.outcome == 'success' && '✅ Passed' || (steps.drawio.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Mermaid Import Extractor | ${{ steps.mermaid.outcome == 'success' && '✅ Passed' || (steps.mermaid.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Optional Motion Contract | ${{ steps.motion.outcome == 'success' && '✅ Passed' || (steps.motion.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Docs & Routing Sync | ${{ steps.docs_sync.outcome == 'success' && '✅ Passed' || (steps.docs_sync.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Packaged Self-Check | ${{ steps.self_check.outcome == 'success' && '✅ Passed' || (steps.self_check.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Primitive Icons & Documentation | ${{ steps.icons.outcome == 'success' && '✅ Passed' || (steps.icons.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
+11 -3
View File
@@ -39,6 +39,8 @@ Every validation gate below must pass before a PR is ready. They also run automa
| Mermaid import path (grammars, adversarial input, caps, docs sync) | `python3 scripts/verify-mermaid-import.py` |
| Optional motion contract (fallbacks, controls, budgets, determinism) | `python3 scripts/test-verify-motion.py` |
| Every shipped motion template/example | `python3 scripts/verify-motion.py --shipped` |
| Docs/routing sync (description hooks, gallery reachability, README tree) | `python3 scripts/verify-docs-sync.py` |
| Packaged output self-check behaves (pass + adversarial cases) | `python3 scripts/test-self-check.py` |
| Generated icon assets are up to date (`icons.html`, `primitive-icons.md`) | `python3 scripts/build-icons.py` then `git diff --exit-code` on the two generated files |
The semantic-pattern gate also caps `skills/diagram-design/SKILL.md` at 40,000 bytes so the installed skill remains practical to load. If that gate fails, reduce duplication or move detail into a routed reference; do not remove routing vocabulary from frontmatter.
@@ -54,7 +56,9 @@ python3 scripts/test-lint-a11y.py \
&& python3 scripts/verify-sequence-oauth.py \
&& python3 scripts/verify-drawio-import.py \
&& python3 scripts/verify-mermaid-import.py \
&& python3 scripts/test-verify-motion.py
&& python3 scripts/test-verify-motion.py \
&& python3 scripts/verify-docs-sync.py \
&& python3 scripts/test-self-check.py
```
### If a gate fails
@@ -98,11 +102,15 @@ New examples should be added to the gallery (`assets/index.html`) so they stay b
Motion is opt-in. Start from `skills/diagram-design/assets/template-motion.html`, follow `references/animation.md`, and run `python3 scripts/verify-motion.py <file>` plus `python3 scripts/test-verify-motion.py`. A motion file must preserve complete no-JavaScript, reduced-motion, print, screenshot, and export states. Keep the controller byte-for-byte identical to the template; changes require updating the canonical template, example, documentation, and adversarial tests together.
## Design decisions (ADRs)
Settled policies live as short records in `docs/adr/` — one pinned motion controller, semantic patterns never expanding the 27-type taxonomy, the reveal-only autoplay rule, and the SKILL.md byte cap with its trigger-rich description requirement. Read the relevant ADR before proposing a change that touches one; when a PR settles a new policy, add an ADR in the same PR.
## Adding a new diagram type
1. Write `skills/diagram-design/references/type-<name>.md` — layout conventions, anti-patterns, and a worked pattern for that type. Mirror an existing reference's structure.
2. Add the row to the selection table in `skills/diagram-design/SKILL.md` §3.
3. Add the three example variants (see above) and register them in the gallery.
2. Add the row to the selection table in `skills/diagram-design/SKILL.md` §3 **and** the type's name to the frontmatter `description` — `verify-docs-sync.py` fails if the description loses or lacks a type's lexical hook.
3. Add the three example variants (see above) and register them in the gallery (`assets/index.html`) — `verify-docs-sync.py` fails on any shipped example the gallery can't reach.
4. Run the full gate suite — new examples are linted automatically by `--all`.
## Changing the icon set
+16 -1
View File
@@ -344,7 +344,8 @@ diagram-design/
│ │ └── primitive-terminal.md
│ ├── scripts/
│ │ ├── drawio_extract.py — draw.io → structured IR
│ │ └── mermaid_extract.py — Mermaid → structured IR
│ │ ├── mermaid_extract.py — Mermaid → structured IR
│ │ └── self_check.py — packaged output self-check (runs installed)
│ └── assets/
│ ├── index.html — live gallery, tabbed
│ ├── template*.html — scaffolds for new diagrams
@@ -359,6 +360,7 @@ diagram-design/
│ ├── sample-flowchart.mmd
│ ├── sample-readme-with-mermaid.md
│ └── sample-adversarial.mmd
├── docs/adr/ — short records of settled design decisions
└── docs/screenshots/ — images used in this README
```
@@ -378,6 +380,8 @@ If you touch the Mermaid import path, `python3 scripts/verify-mermaid-import.py`
it covers all supported grammars, multi-block Markdown, adversarial labels, trust-boundary
behavior, resource caps, named failures, and reference/command wiring.
Docs and routing surfaces are themselves gated: `python3 scripts/verify-docs-sync.py` fails CI if the SKILL.md description loses a type's lexical hook, the gallery can't reach a shipped example, or the README tree names a file that doesn't exist. The skill also ships `skills/diagram-design/scripts/self_check.py` — a distilled output checker installed agents can run on their own generated diagrams; `python3 scripts/test-self-check.py` keeps it honest. Settled design decisions (why one pinned controller, why patterns never add types, the autoplay policy, the SKILL.md byte cap) live as short ADRs in `docs/adr/` — read them before relitigating one, add one when you settle a new policy.
All pull requests and pushes are automatically validated across Linux, Windows, and macOS runners via GitHub Actions CI (`.github/workflows/ci.yml`).
### What loads when
@@ -402,6 +406,17 @@ No matter how many types exist, the agent only reads the one you need. Add a new
---
## It's working if…
- A routine request ("make me a flowchart") loads `SKILL.md` plus exactly one type reference — nothing else.
- Before drawing, the agent states the chosen type, pattern, size, and planned cuts, then renders.
- The output is one `.html` file that opens double-clicked, offline, with no network requests beyond Google Fonts.
- Screen readers announce the diagram's title and description; `prefers-reduced-motion` shows the complete static frame.
- `python3 skills/diagram-design/scripts/self_check.py <file>` prints `OK` on the generated file.
- After brand onboarding, new diagrams use your site's paper, ink, accent, and fonts — with a fidelity receipt naming each.
If any of these fail, that's a bug worth filing.
## The design system (in one paragraph)
One accent color, 1–2 focal elements per diagram. Three font families: Instrument Serif (title + italic callouts), Geist sans (node names), Geist Mono (technical sublabels). 1px hairline borders, no shadows, max border-radius 10px. Every coord, width, and gap divisible by 4 — non-negotiable, it's what keeps the diagrams from feeling AI-generated. Mono is for technical content (ports, URLs, field types), not a blanket "dev" aesthetic. Coral-tinted focal nodes draw the eye to the 1–2 things that matter. Full spec in [`SKILL.md`](skills/diagram-design/SKILL.md#5-design-system).
@@ -0,0 +1,17 @@
# ADR 0001 — Static by default; one pinned controller for motion
**Status:** accepted (v2.3)
## Context
Diagrams are shared as single HTML files and embedded in posts, decks, and docs. Arbitrary inline JavaScript in a shareable artifact is both a security surface and a review burden: every generated file would need its script audited. Motion, however, genuinely clarifies ordered change (queues filling, policy traces diverging).
## Decision
Output is static and script-free by default (`data-motion-mode="none"`). When motion is requested, the file may carry exactly one `<script data-diagram-controls>` block whose body must byte-match the reviewed controller in `assets/template-motion.html`. The match is enforced twice: SHA-256 in `lint-skin.py` and string equality in `verify-motion.py`; the packaged `scripts/self_check.py` repeats the check for installed agents.
## Consequences
- Any behavior change to motion is a change to `template-motion.html`, reviewed once, and re-propagated verbatim; hand-edited controllers fail every gate.
- The controller cannot be extended per-diagram. A diagram needing bespoke interaction is out of scope for this skill.
- The check anchors to the template in the same commit, so a PR that weakens template and copies together still passes the identity check — reviewer attention on `template-motion.html` diffs is the real gate.
@@ -0,0 +1,17 @@
# ADR 0002 — Semantic patterns never expand the 27-type taxonomy
**Status:** accepted (v2.3)
## Context
Auditing behavior-rich figures (queues, policy traces, trust boundaries) showed the skill could arrange boxes but not model system behavior. The obvious fix — new diagram types — would balloon the taxonomy, dilute the selection guide, and force every new behavior into a new layout grammar.
## Decision
Behavior is a separate axis. The seven semantic patterns in `references/semantic-patterns.md` each route to the **nearest existing visual type** for layout; a pattern owns semantic primitives and a tighter budget, never a second layout grammar. The visual-type count stays at 27 unless a genuinely new *layout* grammar appears.
## Consequences
- "27 visual types" is a stable, verifiable claim (`verify-semantic-motion.py` and `verify-docs-sync.py` both count it).
- A new behavior costs one pattern section plus a routing-table row — not a new type reference, template set, and example triple.
- If a pattern ever needs a layout no existing type provides, that is the signal to add a type, with the full §10 shipping set.
@@ -0,0 +1,16 @@
# ADR 0003 — `reveal` is the only sanctioned autoplay
**Status:** accepted (v2.3, clarified after review)
## Context
The motion contract lists "autoplay on load" as an anti-pattern, yet the canonical controller starts a `reveal` run on load. Early drafts of `animation.md` stated both without reconciling them, which reads as a contradiction to anyone copying the template.
## Decision
`reveal` mode may run **once** on initial load — it exists for short ordered explanations where a click-to-start would be friction — and then remains complete. It never restarts on viewport re-entry, tab return, or without an explicit Replay action. Every other mode is user-initiated (`step`) or CSS-scoped (`loop`); `none` stays inert. Under `prefers-reduced-motion: reduce` and without JavaScript, all modes show the complete static frame.
## Consequences
- The anti-pattern is precisely "repeated or attention-trapping autoplay," not "any motion before interaction."
- `verify-motion.py` needs no autoplay heuristic: the pinned controller is the only code that can start a run, and it implements exactly this policy.
@@ -0,0 +1,19 @@
# ADR 0004 — SKILL.md byte cap and the trigger-rich description
**Status:** accepted (v2.3, cap raised after review)
## Context
`SKILL.md` loads into an agent's context on every skill invocation, so it must stay lean; a byte cap keeps growth honest. But v2.3 initially set the cap at 35,000 bytes and slimmed the frontmatter `description` to fit — deleting all 27 type names. The description is the only text an agent sees *before* deciding to load the skill: removing "flowchart", "Gantt", "org chart" from it removes the lexical hooks that make "make me a flowchart" invoke the skill at all.
## Decision
Two rules, in priority order:
1. The frontmatter `description` must name every visual type in the selection table (enforced by `scripts/verify-docs-sync.py`) plus the import formats and major feature vocabulary. Routing surface is never traded for body prose.
2. `MAX_SKILL_BYTES` is 40,000 (enforced by `scripts/verify-semantic-motion.py`). When the file approaches the cap, cut body prose or move detail into `references/` — never the description.
## Consequences
- Adding a visual type requires touching the description; CI fails otherwise, by design.
- The cap is measured on raw bytes with `core.autocrlf=false` pinned in CI checkout; Windows contributors should keep LF endings for `SKILL.md`.
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Adversarial tests for the packaged skill self-check (self_check.py)."""
from __future__ import annotations
import importlib.util
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SELF_CHECK = ROOT / "skills/diagram-design/scripts/self_check.py"
ASSET_DIR = ROOT / "skills/diagram-design/assets"
TEMPLATE = ASSET_DIR / "template-motion.html"
EXAMPLE = ASSET_DIR / "example-policy-trace-animated.html"
STATIC_EXAMPLE = ASSET_DIR / "example-architecture.html"
def load_self_check():
spec = importlib.util.spec_from_file_location("self_check", SELF_CHECK)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def main() -> int:
module = load_self_check()
failures: list[str] = []
def check_pass(label: str, path: Path) -> None:
errors = module.verify(path)
if errors:
failures.append(f"{label}: expected pass, got {errors}")
else:
print(f"OK: {label} passes")
def check_fail(label: str, source: str, needle: str) -> None:
with tempfile.TemporaryDirectory() as scratch:
candidate = Path(scratch) / "candidate.html"
candidate.write_text(source, encoding="utf-8")
errors = module.verify(candidate)
if not any(needle in error for error in errors):
failures.append(f"{label}: expected an error containing {needle!r}, got {errors}")
else:
print(f"OK: {label} rejected")
check_pass("shipped template", TEMPLATE)
check_pass("shipped animated example", EXAMPLE)
check_pass("shipped static example", STATIC_EXAMPLE)
static = STATIC_EXAMPLE.read_text(encoding="utf-8")
animated = EXAMPLE.read_text(encoding="utf-8")
check_fail(
"executable attribute",
static.replace("<body>", '<body onload="fetch(1)">', 1),
"executable attribute",
)
check_fail(
"remote image",
static.replace("<body>", '<body><img src="https://tracker.example/p.gif">', 1),
"remote reference",
)
check_fail(
"lookalike fonts host",
static.replace(
"https://fonts.googleapis.com/css2",
"https://fonts.googleapis.com.evil.tld/css2",
1,
),
"approved Google Fonts",
)
check_fail(
"javascript URL",
static.replace("<body>", '<body><a href="javascript:alert(1)">x</a>', 1),
"executable URL",
)
check_fail(
"data html URL",
static.replace("<body>", '<body><a href="data:text/html,x">x</a>', 1),
"executable URL",
)
check_fail(
"iframe injection",
static.replace("<body>", "<body><iframe></iframe>", 1),
"not allowed",
)
check_fail(
"arbitrary script",
static.replace("</body>", "<script>alert(1)</script></body>", 1),
"canonical data-diagram-controls attribute",
)
check_fail(
"missing svg role",
static.replace('role="img"', 'role="presentation"', 1),
"role=img",
)
check_fail(
"modified controller",
animated.replace("'motion-ready'", "'motion-ready' /* patched */", 1),
"exactly match the controller",
)
check_fail(
"second script",
animated.replace("</body>", "<script data-x></script></body>", 1),
"at most one script",
)
check_fail(
"hidden motion item",
animated.replace(
'<g data-motion-item data-step="1"',
'<g style="opacity:0" data-motion-item data-step="1"',
1,
),
"hidden in source",
)
check_fail(
"missing noscript",
animated.replace("<noscript>", "<div>", 1).replace("</noscript>", "</div>", 1),
"<noscript>",
)
check_fail(
"non-decimal step count",
animated.replace('data-step-count="5"', 'data-step-count="٥"', 1),
"ASCII decimal",
)
if failures:
for failure in failures:
print(f"FAIL: {failure}")
return 1
print("All self-check tests passed")
return 0
if __name__ == "__main__":
sys.exit(main())
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Verify that routing and browsing surfaces stay in sync with the skill.
Three drift classes, each of which has shipped before:
1. The SKILL.md frontmatter description is the only text an agent sees before
deciding to load the skill — every visual type in the selection table must
keep a lexical hook there.
2. The gallery (assets/index.html) must reach every shipped example, and every
gallery tab must point at a file that exists.
3. Every concrete file named in README.md's architecture tree must exist.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SKILL = ROOT / "skills/diagram-design/SKILL.md"
GALLERY = ROOT / "skills/diagram-design/assets/index.html"
ASSET_DIR = ROOT / "skills/diagram-design/assets"
README = ROOT / "README.md"
VARIANTS = ("", "-dark", "-full")
# Types whose selection-table name differs from its description vocabulary.
DESCRIPTION_ALIASES = {
"bar chart": "bar",
"line chart": "line",
"scatter plot": "scatter",
}
def normalized(text: str) -> str:
text = text.casefold()
text = re.sub(r"\s*/\s*", "/", text)
return re.sub(r"\s+", " ", text)
def frontmatter_description(markdown: str) -> str:
parts = markdown.split("---")
if len(parts) < 3:
return ""
match = re.search(r"^description:\s*(.+)$", parts[1], re.MULTILINE)
return match.group(1).strip() if match else ""
def selection_table_types(markdown: str) -> list[str]:
start = markdown.find("### Visual-type guide")
end = markdown.find("Rules of thumb", start)
if start < 0 or end < 0:
return []
names = re.findall(r"^\|[^|]*\|\s*\*\*([^*]+)\*\*\s*\|", markdown[start:end], re.MULTILINE)
return [name.strip() for name in names]
def check_description(errors: list[str]) -> None:
markdown = SKILL.read_text(encoding="utf-8")
description = normalized(frontmatter_description(markdown))
if not description:
errors.append("SKILL.md frontmatter description is missing")
return
types = selection_table_types(markdown)
if len(types) != 27:
errors.append(f"expected 27 visual types in the selection table; found {len(types)}")
for name in types:
key = normalized(name)
key = DESCRIPTION_ALIASES.get(key, key)
if key not in description:
errors.append(
f"description lost the lexical hook for type {name!r} "
f"(expected {key!r} in the SKILL.md frontmatter description)"
)
def gallery_types(source: str) -> list[str]:
return re.findall(r'data-type="([^"]+)"', source)
def check_gallery(errors: list[str]) -> None:
source = GALLERY.read_text(encoding="utf-8")
types = gallery_types(source)
if not types:
errors.append("gallery has no data-type tabs")
return
reachable = {f"example-{name}{variant}.html" for name in types for variant in VARIANTS}
on_disk = {path.name for path in ASSET_DIR.glob("example-*.html")}
for name in sorted(on_disk - reachable):
errors.append(f"gallery cannot reach shipped example {name}; add a tab to assets/index.html")
for name in sorted(types):
if f"example-{name}.html" not in on_disk:
errors.append(f"gallery tab {name!r} points at a missing example-{name}.html")
def readme_tree_tokens(markdown: str) -> list[str]:
blocks = re.findall(r"```\n(diagram-design/\n.*?)```", markdown, re.DOTALL)
tokens: list[str] = []
for block in blocks:
tokens.extend(
re.findall(r"([A-Za-z0-9][A-Za-z0-9_.*-]*\.(?:md|html|py|yml|yaml|json|txt|mmd|drawio|png))", block)
)
return tokens
def check_readme_tree(errors: list[str]) -> None:
markdown = README.read_text(encoding="utf-8")
tokens = readme_tree_tokens(markdown)
if not tokens:
errors.append("README architecture tree not found or names no files")
return
for token in sorted(set(tokens)):
matches = list(ROOT.rglob(token))
if not matches:
errors.append(f"README architecture tree names {token!r} but no such file exists")
def main() -> int:
errors: list[str] = []
check_description(errors)
check_gallery(errors)
check_readme_tree(errors)
if errors:
print("FAIL docs sync")
for error in errors:
print(f" - {error}")
return 1
print("OK docs sync: description hooks, gallery reachability, README tree")
return 0
if __name__ == "__main__":
sys.exit(main())
+7 -1
View File
@@ -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:**
+30
View File
@@ -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;
+389
View File
@@ -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())