fix(motion): harden accessible motion contract
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "diagram-design",
|
"name": "diagram-design",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Branded diagrams across 27 visual types, with semantic system patterns, optional accessible motion, and draw.io or Mermaid import"
|
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling."
|
||||||
},
|
},
|
||||||
"owner": {
|
"owner": {
|
||||||
"name": "Cathryn Lavery"
|
"name": "Cathryn Lavery"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "diagram-design",
|
"name": "diagram-design",
|
||||||
"description": "Branded diagrams across 27 visual types, with semantic system patterns, optional accessible motion, and draw.io or Mermaid import",
|
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"homepage": "https://github.com/cathrynlavery/diagram-design",
|
"homepage": "https://github.com/cathrynlavery/diagram-design",
|
||||||
"repository": "https://github.com/cathrynlavery/diagram-design",
|
"repository": "https://github.com/cathrynlavery/diagram-design",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "diagram-design",
|
"name": "diagram-design",
|
||||||
"description": "Branded diagrams across 27 visual types, with semantic system patterns, optional accessible motion, and draw.io or Mermaid import",
|
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Cathryn Lavery",
|
"name": "Cathryn Lavery",
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"interface": {
|
"interface": {
|
||||||
"displayName": "Diagram Design",
|
"displayName": "Diagram Design",
|
||||||
"shortDescription": "Create editorial technical and product diagrams as standalone HTML.",
|
"shortDescription": "Create editorial technical and product diagrams as standalone HTML.",
|
||||||
"longDescription": "Generate branded technical and product diagrams with semantic system patterns, optional accessible motion, and an opinionated editorial design system.",
|
"longDescription": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
|
||||||
"developerName": "Cathryn Lavery",
|
"developerName": "Cathryn Lavery",
|
||||||
"category": "Productivity",
|
"category": "Productivity",
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
|
|||||||
+6
-4
@@ -8,7 +8,7 @@ Please read [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) first. All contributions ar
|
|||||||
|
|
||||||
## What this project is
|
## What this project is
|
||||||
|
|
||||||
Diagram Design is an agent skill (Claude Code, Codex, Pi) that produces editorial-quality diagrams as self-contained HTML files. The repo is documentation-first: `skills/diagram-design/SKILL.md` is the index, each of the 27 diagram types has its own reference file, and the extractor scripts in `skills/diagram-design/scripts/` turn draw.io and Mermaid sources into a structured IR.
|
Diagram Design is an agent skill (Claude Code, Codex, Pi) that produces editorial-quality diagrams as self-contained HTML files. The repo is documentation-first: `skills/diagram-design/SKILL.md` is the index, each of the 27 visual types has its own reference file, and the extractor scripts in `skills/diagram-design/scripts/` turn draw.io and Mermaid sources into a structured IR.
|
||||||
|
|
||||||
See [README.md](README.md) for the full picture, including the design system and the import/export flows.
|
See [README.md](README.md) for the full picture, including the design system and the import/export flows.
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ Every validation gate below must pass before a PR is ready. They also run automa
|
|||||||
| Accessible SVG contract (unit tests for the a11y linter) | `python3 scripts/test-lint-a11y.py` |
|
| Accessible SVG contract (unit tests for the a11y linter) | `python3 scripts/test-lint-a11y.py` |
|
||||||
| Semantic-pattern routing | `python3 scripts/verify-semantic-motion.py --markdown-only` |
|
| Semantic-pattern routing | `python3 scripts/verify-semantic-motion.py --markdown-only` |
|
||||||
| Animated-example structure and accessibility | `python3 scripts/verify-semantic-motion.py --example-only` |
|
| Animated-example structure and accessibility | `python3 scripts/verify-semantic-motion.py --example-only` |
|
||||||
| Skin conformance of every example (colors, fonts, a11y) | `python3 scripts/lint-skin.py --all --baseline` |
|
| Skin conformance of every example and template (colors, fonts, a11y, assets, scripts) | `python3 scripts/lint-skin.py --all --baseline` |
|
||||||
| A single file, e.g. a new example | `python3 scripts/lint-skin.py skills/diagram-design/assets/example-my-type.html` |
|
| A single file, e.g. a new example | `python3 scripts/lint-skin.py skills/diagram-design/assets/example-my-type.html` |
|
||||||
| Sequence-doc consistency (ATL fragments, budgets) | `python3 scripts/verify-sequence-oauth.py` |
|
| Sequence-doc consistency (ATL fragments, budgets) | `python3 scripts/verify-sequence-oauth.py` |
|
||||||
| draw.io import path (real extractor vs fixtures + docs sync) | `python3 scripts/verify-drawio-import.py` |
|
| draw.io import path (real extractor vs fixtures + docs sync) | `python3 scripts/verify-drawio-import.py` |
|
||||||
@@ -41,6 +41,8 @@ Every validation gate below must pass before a PR is ready. They also run automa
|
|||||||
| Every shipped motion template/example | `python3 scripts/verify-motion.py --shipped` |
|
| Every shipped motion template/example | `python3 scripts/verify-motion.py --shipped` |
|
||||||
| 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 |
|
| 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.
|
||||||
|
|
||||||
Run them all at once before pushing:
|
Run them all at once before pushing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -57,7 +59,7 @@ python3 scripts/test-lint-a11y.py \
|
|||||||
|
|
||||||
### If a gate fails
|
### If a gate fails
|
||||||
|
|
||||||
- **`lint-skin.py`:** the failure message names the file, line, and category (`color`, `font-family`, `a11y`, `external-asset`, `pure-black`, `script`). Colors must come from the palette in `skills/diagram-design/references/style-guide.md`; fonts from the allowed list; diagrams must satisfy the accessible SVG contract (see below).
|
- **`lint-skin.py`:** the failure message names the file, line, and category (`color`, `font-family`, `a11y`, `external-asset`, `pure-black`, `script`). Colors must come from the palette in `skills/diagram-design/references/style-guide.md`; fonts from the allowed list; diagrams must satisfy the accessible SVG contract (see below). The linter also requires the SHA-pinned controller from `template-motion.html` verbatim and rejects remote resources, CSS `@import`, non-fragment CSS `url()`, event handlers, `srcdoc`, executable URLs, and extra scripts.
|
||||||
- **`verify-*.py`:** the extractor's real behavior no longer matches its fixture or the documentation, or the reference/command/prompt wiring drifted. Fix the source of truth — do not widen a test to avoid a failure.
|
- **`verify-*.py`:** the extractor's real behavior no longer matches its fixture or the documentation, or the reference/command/prompt wiring drifted. Fix the source of truth — do not widen a test to avoid a failure.
|
||||||
- **Icon assets:** you changed `scripts/vendor/icons/` or `scripts/build-icons.py` and the generated files went stale. Rerun `python3 scripts/build-icons.py` and commit the regenerated files.
|
- **Icon assets:** you changed `scripts/vendor/icons/` or `scripts/build-icons.py` and the generated files went stale. Rerun `python3 scripts/build-icons.py` and commit the regenerated files.
|
||||||
|
|
||||||
@@ -94,7 +96,7 @@ python3 scripts/lint-skin.py skills/diagram-design/assets/example-my-type.html
|
|||||||
|
|
||||||
New examples should be added to the gallery (`assets/index.html`) so they stay browsable.
|
New examples should be added to the gallery (`assets/index.html`) so they stay browsable.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Adding a new diagram type
|
## Adding a new diagram type
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ No Figma. No generic rounded boxes. No 30-minute color-picking sessions.
|
|||||||
|
|
||||||
I write at [littlemight.com](https://littlemight.com?utm_source=diagram-design&utm_medium=readme&utm_campaign=github&utm_content=intro) (and run [BestSelf.co](https://bestself.co?utm_source=diagram-design&utm_medium=readme&utm_campaign=github&utm_content=intro) on the side). Every time I needed a diagram — an architecture sketch, a flowchart, a pyramid of what matters most — I'd ask Claude and get back a generic rounded-box thing that looked nothing like the rest of the site. I'd either fight with Figma for 30 minutes or just skip the diagram.
|
I write at [littlemight.com](https://littlemight.com?utm_source=diagram-design&utm_medium=readme&utm_campaign=github&utm_content=intro) (and run [BestSelf.co](https://bestself.co?utm_source=diagram-design&utm_medium=readme&utm_campaign=github&utm_content=intro) on the side). Every time I needed a diagram — an architecture sketch, a flowchart, a pyramid of what matters most — I'd ask Claude and get back a generic rounded-box thing that looked nothing like the rest of the site. I'd either fight with Figma for 30 minutes or just skip the diagram.
|
||||||
|
|
||||||
So I built a Claude Code skill for it. Twenty-seven types, editorial quality, matches your brand in 60 seconds by reading your website.
|
So I built a Claude Code skill for it. Twenty-seven visual types, editorial quality, matches your brand in 60 seconds by reading your website.
|
||||||
|
|
||||||
> *The highest-quality move is usually deletion.* Every node earns its place. The accent color is reserved for the 1–2 things the reader should look at first. Target density: 4/10.
|
> *The highest-quality move is usually deletion.* Every node earns its place. The accent color is reserved for the 1–2 things the reader should look at first. Target density: 4/10.
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ So I built a Claude Code skill for it. Twenty-seven types, editorial quality, ma
|
|||||||
|
|
||||||
## What it makes
|
## What it makes
|
||||||
|
|
||||||
All 27 diagrams ship in three static variants: minimal light, minimal dark, and full-editorial. Open any of them directly in a browser. There is no build step, JavaScript, or external image dependency.
|
All 27 visual types ship in three static variants: minimal light, minimal dark, and full-editorial. Open any of them directly in a browser. There is no build step, JavaScript, or external image dependency.
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -208,13 +208,14 @@ Your agent will pick the right type, build the HTML, and save it. You can also s
|
|||||||
```bash
|
```bash
|
||||||
cp skills/diagram-design/assets/template.html my-diagram.html # minimal light
|
cp skills/diagram-design/assets/template.html my-diagram.html # minimal light
|
||||||
cp skills/diagram-design/assets/template-full.html my-diagram.html # editorial with summary cards
|
cp skills/diagram-design/assets/template-full.html my-diagram.html # editorial with summary cards
|
||||||
|
cp skills/diagram-design/assets/template-motion.html my-diagram.html # optional accessible motion
|
||||||
```
|
```
|
||||||
|
|
||||||
### Semantic patterns and optional motion
|
### Semantic patterns and optional motion
|
||||||
|
|
||||||
When behavior matters, the skill chooses a semantic pattern first and a visual type second. The seven routed patterns cover fan-in queues and bottlenecks, repeated stage slots, unstructured-input transformation, paired policy traces, secure paved roads, governance catalogs, and compensating security layers. Each pattern defines its triggers, primitives, budget, anti-patterns, static fallback, and nearest visual type in [`semantic-patterns.md`](skills/diagram-design/references/semantic-patterns.md).
|
When behavior matters, the skill chooses a semantic pattern first and a visual type second. The seven routed patterns cover fan-in queues and bottlenecks, repeated stage slots, unstructured-input transformation, paired policy traces, secure paved roads, governance catalogs, and compensating security layers. Each pattern defines its triggers, primitives, budget, anti-patterns, static fallback, and nearest visual type in [`semantic-patterns.md`](skills/diagram-design/references/semantic-patterns.md).
|
||||||
|
|
||||||
Motion is optional and does not create another diagram type. [`animation.md`](skills/diagram-design/references/animation.md) defines `none`, `reveal`, `step`, and `loop` modes with a complete static first frame, deterministic timing, and controls when interaction is available. Reduced-motion output shows the complete static frame and hides/disables playback controls. Motion HTML uses the exact reviewed controller from `template-motion.html`; arbitrary inline scripts are rejected. The default is `none`: ordinary output remains static and script-free. [`example-policy-trace-animated.html`](skills/diagram-design/assets/example-policy-trace-animated.html) is the self-contained interactive example.
|
Motion is optional and does not create another visual type. [`animation.md`](skills/diagram-design/references/animation.md) defines `none`, `reveal`, `step`, and `loop` modes with a complete static first frame, deterministic timing, and controls when interaction is available. Reduced-motion output shows the complete static frame and hides/disables playback controls. Motion HTML uses the exact reviewed controller from `template-motion.html`; arbitrary or modified inline scripts, remote assets, CSS imports, and executable HTML attributes are rejected. The default is `none`: ordinary output remains static and script-free. [`example-policy-trace-animated.html`](skills/diagram-design/assets/example-policy-trace-animated.html) is the self-contained interactive example.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -295,6 +296,8 @@ Or just ask in natural language:
|
|||||||
|
|
||||||
Both formats are diagram-only — editorial cards and headers from `-full` variants aren't included. For a screenshot of the full editorial layout, use your browser's print-to-PDF or full-page screenshot. See [`skills/diagram-design/references/export.md`](skills/diagram-design/references/export.md) for the full procedure.
|
Both formats are diagram-only — editorial cards and headers from `-full` variants aren't included. For a screenshot of the full editorial layout, use your browser's print-to-PDF or full-page screenshot. See [`skills/diagram-design/references/export.md`](skills/diagram-design/references/export.md) for the full procedure.
|
||||||
|
|
||||||
|
For motion-enabled HTML, export the explicit final state: open `?motion=static`, wait for `document.fonts.ready`, and confirm the motion root has `data-frame="static"` before capture. Use `?motion=step&step=N` only when a named intermediate frame was requested.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -364,10 +367,10 @@ This keeps the agent's working context tight: routine diagrams load one type ref
|
|||||||
### Contributing / skin lint
|
### Contributing / skin lint
|
||||||
|
|
||||||
Before submitting a new example, run `python3 scripts/lint-skin.py <your-new-example.html>`.
|
Before submitting a new example, run `python3 scripts/lint-skin.py <your-new-example.html>`.
|
||||||
The repository-wide check `python3 scripts/lint-skin.py --all --baseline` must stay green.
|
The repository-wide check `python3 scripts/lint-skin.py --all --baseline` covers examples and templates and must stay green.
|
||||||
Semantic routing must pass `python3 scripts/verify-semantic-motion.py --markdown-only`; the animated example has a separate `--example-only` gate. Every shipped motion template/example must also pass `python3 scripts/verify-motion.py --shipped`.
|
CI separately verifies semantic routing, animated-example structure, animated skin, every shipped motion asset, and adversarial mutations of the controller contract, reporting later gate outcomes even when an earlier gate fails. Semantic routing must pass `python3 scripts/verify-semantic-motion.py --markdown-only`; the animated example has a separate `--example-only` gate. Every shipped motion template/example must also pass `python3 scripts/verify-motion.py --shipped`.
|
||||||
The linter's `a11y` category rejects diagram SVGs without a resolving accessible name,
|
The linter's `a11y` category rejects diagram SVGs without a resolving accessible name,
|
||||||
an empty or misplaced title/description, or unsafe bare `title` / `desc` IDs.
|
an empty or misplaced title/description, or unsafe bare `title` / `desc` IDs. It also pins the exact reviewed motion controller and rejects remote assets, CSS `@import`, non-fragment CSS `url()`, and executable attributes such as `onclick` or `srcdoc`.
|
||||||
If you touch the draw.io import path, `python3 scripts/verify-drawio-import.py` must also pass —
|
If you touch the draw.io import path, `python3 scripts/verify-drawio-import.py` must also pass —
|
||||||
it drives the real extractor against `scripts/fixtures/sample-architecture.drawio` in all four
|
it drives the real extractor against `scripts/fixtures/sample-architecture.drawio` in all four
|
||||||
container formats and checks the references stay in sync.
|
container formats and checks the references stay in sync.
|
||||||
|
|||||||
+28
-8
@@ -309,7 +309,7 @@ class AccessibleSvgParser(HTMLParser):
|
|||||||
element.text.append(data)
|
element.text.append(data)
|
||||||
|
|
||||||
|
|
||||||
def lint_accessible_svgs(text, expected_slug):
|
def lint_accessible_svgs(text, expected_slug, allow_template_placeholders=False):
|
||||||
parser = AccessibleSvgParser(text)
|
parser = AccessibleSvgParser(text)
|
||||||
parser.feed(text)
|
parser.feed(text)
|
||||||
parser.close()
|
parser.close()
|
||||||
@@ -352,7 +352,11 @@ def lint_accessible_svgs(text, expected_slug):
|
|||||||
if "[" in element_id or "]" in element_id
|
if "[" in element_id or "]" in element_id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if placeholder_ids:
|
allowed_placeholders = {"[diagram-slug]-title", "[diagram-slug]-desc"}
|
||||||
|
if placeholder_ids and (
|
||||||
|
not allow_template_placeholders
|
||||||
|
or not set(placeholder_ids).issubset(allowed_placeholders)
|
||||||
|
):
|
||||||
add(
|
add(
|
||||||
svg.line,
|
svg.line,
|
||||||
svg.offset,
|
svg.offset,
|
||||||
@@ -499,8 +503,12 @@ def named_families(value, allowed_fonts):
|
|||||||
return families
|
return families
|
||||||
|
|
||||||
|
|
||||||
def lint_text(text, colors, rgb_triplets, expected_slug):
|
def lint_text(
|
||||||
findings = lint_accessible_svgs(text, expected_slug)
|
text, colors, rgb_triplets, expected_slug, allow_template_placeholders=False
|
||||||
|
):
|
||||||
|
findings = lint_accessible_svgs(
|
||||||
|
text, expected_slug, allow_template_placeholders
|
||||||
|
)
|
||||||
allowed_fonts = ALLOWED_FONTS | style_guide_families()
|
allowed_fonts = ALLOWED_FONTS | style_guide_families()
|
||||||
|
|
||||||
resources = ResourceParser()
|
resources = ResourceParser()
|
||||||
@@ -654,7 +662,7 @@ def parse_args():
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--all",
|
"--all",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="lint every skills/diagram-design/assets/example-*.html file",
|
help="lint every skills/diagram-design/assets/example-*.html and template*.html file",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--baseline",
|
"--baseline",
|
||||||
@@ -677,7 +685,10 @@ def main():
|
|||||||
skipped = 0
|
skipped = 0
|
||||||
baseline = set()
|
baseline = set()
|
||||||
if args.all:
|
if args.all:
|
||||||
paths = sorted(ASSET_DIR.glob("example-*.html"))
|
paths = sorted(
|
||||||
|
set(ASSET_DIR.glob("example-*.html"))
|
||||||
|
| set(ASSET_DIR.glob("template*.html"))
|
||||||
|
)
|
||||||
if args.baseline:
|
if args.baseline:
|
||||||
baseline = load_baseline()
|
baseline = load_baseline()
|
||||||
for path in paths:
|
for path in paths:
|
||||||
@@ -693,10 +704,19 @@ def main():
|
|||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
relative = display_path(path)
|
relative = display_path(path)
|
||||||
expected_slug = diagram_slug(path)
|
expected_slug = diagram_slug(path)
|
||||||
|
allow_template_placeholders = path.name.startswith("template")
|
||||||
if args.baseline and (path.name in baseline or relative in baseline):
|
if args.baseline and (path.name in baseline or relative in baseline):
|
||||||
findings = lint_accessible_svgs(text, expected_slug)
|
findings = lint_accessible_svgs(
|
||||||
|
text, expected_slug, allow_template_placeholders
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
findings = lint_text(text, colors, rgb_triplets, expected_slug)
|
findings = lint_text(
|
||||||
|
text,
|
||||||
|
colors,
|
||||||
|
rgb_triplets,
|
||||||
|
expected_slug,
|
||||||
|
allow_template_placeholders,
|
||||||
|
)
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
findings = [(0, 0, "read-error", str(error))]
|
findings = [(0, 0, "read-error", str(error))]
|
||||||
|
|
||||||
|
|||||||
@@ -436,6 +436,13 @@ def main() -> int:
|
|||||||
(project / "skills/diagram-design/assets/example-baseline.html").write_text(
|
(project / "skills/diagram-design/assets/example-baseline.html").write_text(
|
||||||
'<svg style="color: #123456"></svg>\n', encoding="utf-8"
|
'<svg style="color: #123456"></svg>\n', encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
template_svg = VALID_SVG.replace(
|
||||||
|
"fixture-title", "[diagram-slug]-title"
|
||||||
|
).replace("fixture-desc", "[diagram-slug]-desc")
|
||||||
|
(project / "skills/diagram-design/assets/template.html").write_text(
|
||||||
|
'<img src="https://evil.example/template.png">\n' + template_svg,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
baseline_result = subprocess.run(
|
baseline_result = subprocess.run(
|
||||||
[
|
[
|
||||||
sys.executable,
|
sys.executable,
|
||||||
@@ -463,7 +470,18 @@ def main() -> int:
|
|||||||
"baseline-a11y: legacy visual finding was not exempted\n"
|
"baseline-a11y: legacy visual finding was not exempted\n"
|
||||||
f"{baseline_result.stdout}"
|
f"{baseline_result.stdout}"
|
||||||
)
|
)
|
||||||
|
if "external-asset: external resource in <img> src is not allowed" not in baseline_result.stdout:
|
||||||
|
raise AssertionError(
|
||||||
|
"template-scan: --all did not lint template HTML\n"
|
||||||
|
f"{baseline_result.stdout}"
|
||||||
|
)
|
||||||
|
if "unresolved placeholder" in baseline_result.stdout:
|
||||||
|
raise AssertionError(
|
||||||
|
"template-scan: canonical template placeholders were rejected\n"
|
||||||
|
f"{baseline_result.stdout}"
|
||||||
|
)
|
||||||
print("OK: baseline file receives a11y checks but keeps visual exemptions")
|
print("OK: baseline file receives a11y checks but keeps visual exemptions")
|
||||||
|
print("OK: --all scans templates and permits canonical slug placeholders")
|
||||||
|
|
||||||
print("All accessible-SVG lint cases passed.")
|
print("All accessible-SVG lint cases passed.")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -104,9 +104,10 @@ def main() -> int:
|
|||||||
print("OK: shipped animated example accepted")
|
print("OK: shipped animated example accepted")
|
||||||
|
|
||||||
shipped = module.shipped_motion_files()
|
shipped = module.shipped_motion_files()
|
||||||
if shipped != sorted([TEMPLATE, EXAMPLE], key=lambda path: path.as_posix()):
|
required_shipped = {TEMPLATE, EXAMPLE}
|
||||||
raise AssertionError(f"unexpected shipped motion inventory: {shipped}")
|
if not required_shipped.issubset(set(shipped)):
|
||||||
print("OK: shipped motion inventory is parsed and complete")
|
raise AssertionError(f"incomplete shipped motion inventory: {shipped}")
|
||||||
|
print("OK: required shipped motion inventory is parsed and complete")
|
||||||
check(
|
check(
|
||||||
"bad-mode",
|
"bad-mode",
|
||||||
source.replace('data-motion-mode="step"', 'data-motion-mode="cinematic"', 1),
|
source.replace('data-motion-mode="step"', 'data-motion-mode="cinematic"', 1),
|
||||||
@@ -132,6 +133,19 @@ def main() -> int:
|
|||||||
source.replace('aria-live="polite"', 'aria-live="assertive"', 1),
|
source.replace('aria-live="polite"', 'aria-live="assertive"', 1),
|
||||||
"motion status needs role=status",
|
"motion status needs role=status",
|
||||||
)
|
)
|
||||||
|
status_line = (
|
||||||
|
' <p class="sr-only" data-motion-status role="status" '
|
||||||
|
'aria-live="polite" aria-atomic="true"></p>'
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"status-inside-hidden-controls",
|
||||||
|
source.replace(
|
||||||
|
f" </div>\n{status_line}",
|
||||||
|
f"{status_line}\n </div>",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"motion status must be outside data-motion-controls",
|
||||||
|
)
|
||||||
check(
|
check(
|
||||||
"hidden-fallback",
|
"hidden-fallback",
|
||||||
source.replace('data-motion-item data-step="1"', 'data-motion-item data-step="1" style="opacity:0"', 1),
|
source.replace('data-motion-item data-step="1"', 'data-motion-item data-step="1" style="opacity:0"', 1),
|
||||||
@@ -147,6 +161,15 @@ def main() -> int:
|
|||||||
source.replace("prefers-reduced-motion", "user-reduced-motion"),
|
source.replace("prefers-reduced-motion", "user-reduced-motion"),
|
||||||
"missing reduced-motion CSS fallback",
|
"missing reduced-motion CSS fallback",
|
||||||
)
|
)
|
||||||
|
check(
|
||||||
|
"comment-cannot-spoof-reduced-motion",
|
||||||
|
source.replace(
|
||||||
|
"@media (prefers-reduced-motion: reduce)",
|
||||||
|
"@media (user-reduced-motion: reduce)",
|
||||||
|
1,
|
||||||
|
).replace("</style>", " /* prefers-reduced-motion */\n </style>", 1),
|
||||||
|
"missing reduced-motion CSS fallback",
|
||||||
|
)
|
||||||
check(
|
check(
|
||||||
"reduced-motion-controls-visible",
|
"reduced-motion-controls-visible",
|
||||||
source.replace(
|
source.replace(
|
||||||
@@ -161,6 +184,15 @@ def main() -> int:
|
|||||||
source.replace("[data-motion-controls][hidden] { display: none !important; }", "", 1),
|
source.replace("[data-motion-controls][hidden] { display: none !important; }", "", 1),
|
||||||
"missing hidden control override",
|
"missing hidden control override",
|
||||||
)
|
)
|
||||||
|
check(
|
||||||
|
"comment-cannot-spoof-script-contract",
|
||||||
|
source.replace("'visibilitychange'", "'pagehide'", 1).replace(
|
||||||
|
" })();",
|
||||||
|
" // visibilitychange\n })();",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"missing background-tab pause",
|
||||||
|
)
|
||||||
check(
|
check(
|
||||||
"missing-static-override",
|
"missing-static-override",
|
||||||
source.replace('data-motion="static"', 'data-motion="still"'),
|
source.replace('data-motion="static"', 'data-motion="still"'),
|
||||||
@@ -180,6 +212,61 @@ def main() -> int:
|
|||||||
source.replace('aria-label="Step 1: Request received"', 'aria-description="accent stage"', 1),
|
source.replace('aria-label="Step 1: Request received"', 'aria-description="accent stage"', 1),
|
||||||
"needs a non-color aria-label",
|
"needs a non-color aria-label",
|
||||||
)
|
)
|
||||||
|
check(
|
||||||
|
"unicode-step-count",
|
||||||
|
source.replace('data-step-count="5"', 'data-step-count="٥"', 1),
|
||||||
|
"data-step-count must be an ASCII decimal integer",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"underscored-item-step",
|
||||||
|
source.replace('data-step="1"', 'data-step="1_0"', 1),
|
||||||
|
"non-ASCII-decimal data-step",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"double-space-ready-scope",
|
||||||
|
source.replace(
|
||||||
|
".motion-ready [data-motion-item] {",
|
||||||
|
".motion-ready [data-motion-item] {",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"comma-unscoped-opacity",
|
||||||
|
source.replace(
|
||||||
|
".motion-ready [data-motion-item] {\n opacity: .12;",
|
||||||
|
".motion-ready [data-motion-item], [data-motion-item] {\n opacity: 0;",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"unscoped data-motion-item hiding",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"unscoped-visibility",
|
||||||
|
source.replace(
|
||||||
|
"</style>",
|
||||||
|
" [data-motion-root] > [data-motion-item] { visibility: hidden; }\n </style>",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"unscoped data-motion-item hiding",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"unscoped-display",
|
||||||
|
source.replace(
|
||||||
|
"</style>",
|
||||||
|
" [data-motion-item] { display: none; }\n </style>",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"unscoped data-motion-item hiding",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"unscoped-infinite-iteration-count",
|
||||||
|
source.replace(
|
||||||
|
"</style>",
|
||||||
|
" .motion-ready [data-motion-item] { animation-iteration-count: infinite; }\n </style>",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"infinite animation must be scoped to data-motion-mode=loop",
|
||||||
|
)
|
||||||
check(
|
check(
|
||||||
"null-test-frame",
|
"null-test-frame",
|
||||||
source.replace("requestedStep !== null", "true", 1),
|
source.replace("requestedStep !== null", "true", 1),
|
||||||
@@ -195,6 +282,15 @@ def main() -> int:
|
|||||||
source.replace("/^\\d+$/.test(requestedStep)", "/^-?\\d+$/.test(requestedStep)", 1),
|
source.replace("/^\\d+$/.test(requestedStep)", "/^-?\\d+$/.test(requestedStep)", 1),
|
||||||
"missing non-negative decimal test frame validation",
|
"missing non-negative decimal test frame validation",
|
||||||
)
|
)
|
||||||
|
check(
|
||||||
|
"modified-replay-shortcut",
|
||||||
|
source.replace(
|
||||||
|
"!event.ctrlKey && !event.metaKey && !event.altKey && ",
|
||||||
|
"",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"missing modified-shortcut guard",
|
||||||
|
)
|
||||||
check(
|
check(
|
||||||
"over-budget-test-frame",
|
"over-budget-test-frame",
|
||||||
source.replace(" && parsedStep <= count", "", 1),
|
source.replace(" && parsedStep <= count", "", 1),
|
||||||
@@ -209,6 +305,16 @@ def main() -> int:
|
|||||||
),
|
),
|
||||||
"must exactly match the controller in template-motion.html",
|
"must exactly match the controller in template-motion.html",
|
||||||
)
|
)
|
||||||
|
ready_line = " root.classList.add('motion-ready');\n"
|
||||||
|
check(
|
||||||
|
"early-motion-ready",
|
||||||
|
source.replace(ready_line, "", 1).replace(
|
||||||
|
" if (staticOverride) {",
|
||||||
|
ready_line + " if (staticOverride) {",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"motion-ready must be added only after the initial render succeeds",
|
||||||
|
)
|
||||||
check(
|
check(
|
||||||
"remote-controller",
|
"remote-controller",
|
||||||
source.replace(
|
source.replace(
|
||||||
@@ -257,6 +363,57 @@ def main() -> int:
|
|||||||
raise AssertionError(f"broken Trace B connector was accepted: {trace_errors}")
|
raise AssertionError(f"broken Trace B connector was accepted: {trace_errors}")
|
||||||
print("OK: Trace B connector cannot continue through terminal rows")
|
print("OK: Trace B connector cannot continue through terminal rows")
|
||||||
|
|
||||||
|
decorative_example = directory / "decorative-trace.html"
|
||||||
|
decorative_example.write_text(
|
||||||
|
EXAMPLE.read_text(encoding="utf-8").replace(
|
||||||
|
" </svg>",
|
||||||
|
' <circle data-motion-item data-step="3" data-motion-decorative '
|
||||||
|
'aria-hidden="true" focusable="false" cx="0" cy="0" r="1"/>\n'
|
||||||
|
" </svg>",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
decorative_errors = semantic_module.verify_example(decorative_example)
|
||||||
|
if decorative_errors:
|
||||||
|
raise AssertionError(
|
||||||
|
f"decorative semantic-verifier item was rejected: {decorative_errors}"
|
||||||
|
)
|
||||||
|
print("OK: semantic verifier accepts an accessible decorative motion item")
|
||||||
|
|
||||||
|
original_skill = semantic_module.SKILL
|
||||||
|
try:
|
||||||
|
missing_router = directory / "missing-router.md"
|
||||||
|
missing_router.write_text(
|
||||||
|
original_skill.read_text(encoding="utf-8").replace(
|
||||||
|
"semantic-patterns.md", "patterns.md"
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
semantic_module.SKILL = missing_router
|
||||||
|
markdown_errors = semantic_module.verify_markdown()
|
||||||
|
finally:
|
||||||
|
semantic_module.SKILL = original_skill
|
||||||
|
if not any("must link to semantic-patterns.md" in error for error in markdown_errors):
|
||||||
|
raise AssertionError(f"missing semantic router was accepted: {markdown_errors}")
|
||||||
|
print("OK: missing semantic-pattern router anchor is rejected")
|
||||||
|
|
||||||
|
try:
|
||||||
|
missing_guide = directory / "missing-guide.md"
|
||||||
|
missing_guide.write_text(
|
||||||
|
original_skill.read_text(encoding="utf-8").replace(
|
||||||
|
"### Visual-type guide (27)", "### Visual guide"
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
semantic_module.SKILL = missing_guide
|
||||||
|
markdown_errors = semantic_module.verify_markdown()
|
||||||
|
finally:
|
||||||
|
semantic_module.SKILL = original_skill
|
||||||
|
if not any("must contain the 27-row visual-type guide" in error for error in markdown_errors):
|
||||||
|
raise AssertionError(f"missing visual-guide anchor was accepted: {markdown_errors}")
|
||||||
|
print("OK: missing visual-type guide anchor is rejected")
|
||||||
|
|
||||||
cli_result = subprocess.run(
|
cli_result = subprocess.run(
|
||||||
[sys.executable, str(VERIFIER), str(TEMPLATE)],
|
[sys.executable, str(VERIFIER), str(TEMPLATE)],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
|
|||||||
+208
-23
@@ -15,6 +15,7 @@ ASSET_DIR = ROOT / "skills/diagram-design/assets"
|
|||||||
MOTION_TEMPLATE = ASSET_DIR / "template-motion.html"
|
MOTION_TEMPLATE = ASSET_DIR / "template-motion.html"
|
||||||
MODES = {"none", "reveal", "step", "loop"}
|
MODES = {"none", "reveal", "step", "loop"}
|
||||||
ACTIONS = {"play", "pause", "replay", "prev", "next"}
|
ACTIONS = {"play", "pause", "replay", "prev", "next"}
|
||||||
|
ASCII_DECIMAL_RE = re.compile(r"^[0-9]+$")
|
||||||
|
|
||||||
|
|
||||||
class MotionParser(HTMLParser):
|
class MotionParser(HTMLParser):
|
||||||
@@ -25,14 +26,18 @@ class MotionParser(HTMLParser):
|
|||||||
self.actions: set[str] = set()
|
self.actions: set[str] = set()
|
||||||
self.controls = 0
|
self.controls = 0
|
||||||
self.statuses: list[dict[str, str]] = []
|
self.statuses: list[dict[str, str]] = []
|
||||||
|
self.statuses_in_controls = 0
|
||||||
self.scripts: list[dict[str, object]] = []
|
self.scripts: list[dict[str, object]] = []
|
||||||
|
self.styles: list[dict[str, object]] = []
|
||||||
self.svgs: list[dict[str, object]] = []
|
self.svgs: list[dict[str, object]] = []
|
||||||
self._svg_depth = 0
|
self._svg_depth = 0
|
||||||
self._current_svg: dict[str, object] | None = None
|
self._current_svg: dict[str, object] | None = None
|
||||||
self._capture: str | None = None
|
self._capture: str | None = None
|
||||||
self._current_script: dict[str, object] | None = None
|
self._current_script: dict[str, object] | None = None
|
||||||
|
self._current_style: dict[str, object] | None = None
|
||||||
self._element_stack: list[str] = []
|
self._element_stack: list[str] = []
|
||||||
self._motion_root_depth: int | None = None
|
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:
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||||
tag = tag.casefold()
|
tag = tag.casefold()
|
||||||
@@ -48,10 +53,14 @@ class MotionParser(HTMLParser):
|
|||||||
self.items.append(data)
|
self.items.append(data)
|
||||||
if "data-motion-action" in data:
|
if "data-motion-action" in data:
|
||||||
self.actions.add(data["data-motion-action"])
|
self.actions.add(data["data-motion-action"])
|
||||||
if "data-motion-status" in data:
|
|
||||||
self.statuses.append(data)
|
|
||||||
if "data-motion-controls" in data:
|
if "data-motion-controls" in data:
|
||||||
self.controls += 1
|
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":
|
if tag == "script":
|
||||||
self._current_script = {
|
self._current_script = {
|
||||||
"attrs": data,
|
"attrs": data,
|
||||||
@@ -60,6 +69,9 @@ class MotionParser(HTMLParser):
|
|||||||
"closed": False,
|
"closed": False,
|
||||||
}
|
}
|
||||||
self.scripts.append(self._current_script)
|
self.scripts.append(self._current_script)
|
||||||
|
if tag == "style":
|
||||||
|
self._current_style = {"body": [], "closed": False}
|
||||||
|
self.styles.append(self._current_style)
|
||||||
self._element_stack.append(tag)
|
self._element_stack.append(tag)
|
||||||
if tag == "svg":
|
if tag == "svg":
|
||||||
self._svg_depth = 1
|
self._svg_depth = 1
|
||||||
@@ -80,6 +92,9 @@ class MotionParser(HTMLParser):
|
|||||||
if tag == "script" and self._current_script is not None:
|
if tag == "script" and self._current_script is not None:
|
||||||
self._current_script["closed"] = True
|
self._current_script["closed"] = True
|
||||||
self._current_script = None
|
self._current_script = None
|
||||||
|
if tag == "style" and self._current_style is not None:
|
||||||
|
self._current_style["closed"] = True
|
||||||
|
self._current_style = None
|
||||||
if self._svg_depth:
|
if self._svg_depth:
|
||||||
if tag in {"title", "desc"}:
|
if tag in {"title", "desc"}:
|
||||||
self._capture = None
|
self._capture = None
|
||||||
@@ -95,12 +110,21 @@ class MotionParser(HTMLParser):
|
|||||||
and len(self._element_stack) <= self._motion_root_depth
|
and len(self._element_stack) <= self._motion_root_depth
|
||||||
):
|
):
|
||||||
self._motion_root_depth = None
|
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:
|
def handle_data(self, data: str) -> None:
|
||||||
if self._current_script is not None:
|
if self._current_script is not None:
|
||||||
body = self._current_script["body"]
|
body = self._current_script["body"]
|
||||||
assert isinstance(body, list)
|
assert isinstance(body, list)
|
||||||
body.append(data)
|
body.append(data)
|
||||||
|
if self._current_style is not None:
|
||||||
|
body = self._current_style["body"]
|
||||||
|
assert isinstance(body, list)
|
||||||
|
body.append(data)
|
||||||
if self._capture and self._current_svg:
|
if self._capture and self._current_svg:
|
||||||
node = self._current_svg[self._capture]
|
node = self._current_svg[self._capture]
|
||||||
assert isinstance(node, dict)
|
assert isinstance(node, dict)
|
||||||
@@ -118,6 +142,111 @@ def parsed_document(source: str) -> MotionParser:
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def without_comments(source: str) -> str:
|
||||||
|
"""Remove JS/CSS comments while preserving quoted strings and regex literals."""
|
||||||
|
output: list[str] = []
|
||||||
|
quote: str | None = None
|
||||||
|
index = 0
|
||||||
|
while index < len(source):
|
||||||
|
character = source[index]
|
||||||
|
following = source[index + 1] if index + 1 < len(source) else ""
|
||||||
|
if quote is not None:
|
||||||
|
output.append(character)
|
||||||
|
if character == "\\" and index + 1 < len(source):
|
||||||
|
index += 1
|
||||||
|
output.append(source[index])
|
||||||
|
elif character == quote:
|
||||||
|
quote = None
|
||||||
|
elif character in {"'", '"', "`"}:
|
||||||
|
quote = character
|
||||||
|
output.append(character)
|
||||||
|
elif character == "/" and following == "*":
|
||||||
|
index += 2
|
||||||
|
while index < len(source) - 1 and source[index : index + 2] != "*/":
|
||||||
|
if source[index] in "\r\n":
|
||||||
|
output.append(source[index])
|
||||||
|
index += 1
|
||||||
|
index += 1
|
||||||
|
elif character == "/" and following == "/":
|
||||||
|
index += 2
|
||||||
|
while index < len(source) and source[index] not in "\r\n":
|
||||||
|
index += 1
|
||||||
|
if index < len(source):
|
||||||
|
output.append(source[index])
|
||||||
|
else:
|
||||||
|
output.append(character)
|
||||||
|
index += 1
|
||||||
|
return "".join(output)
|
||||||
|
|
||||||
|
|
||||||
|
def parsed_bodies(blocks: list[dict[str, object]]) -> str:
|
||||||
|
bodies = []
|
||||||
|
for block in blocks:
|
||||||
|
body = block["body"]
|
||||||
|
assert isinstance(body, list)
|
||||||
|
bodies.append("".join(body))
|
||||||
|
return without_comments("\n".join(bodies))
|
||||||
|
|
||||||
|
|
||||||
|
def css_at_rule_blocks(source: str, header_pattern: str) -> list[str]:
|
||||||
|
"""Return balanced bodies for CSS at-rules whose headers match a pattern."""
|
||||||
|
blocks: list[str] = []
|
||||||
|
for match in re.finditer(header_pattern + r"\s*\{", source, re.IGNORECASE):
|
||||||
|
depth = 1
|
||||||
|
index = match.end()
|
||||||
|
start = index
|
||||||
|
while index < len(source) and depth:
|
||||||
|
if source[index] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif source[index] == "}":
|
||||||
|
depth -= 1
|
||||||
|
index += 1
|
||||||
|
if depth == 0:
|
||||||
|
blocks.append(source[start : index - 1])
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def hidden_unscoped_motion_selectors(source: str) -> list[str]:
|
||||||
|
"""Find selectors that hide motion items before progressive enhancement."""
|
||||||
|
hidden_declaration = re.compile(
|
||||||
|
r"(?:opacity\s*:\s*(?:0+(?:\.0*)?|\.0+)\b|"
|
||||||
|
r"visibility\s*:\s*hidden\b|display\s*:\s*none\b)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
findings: list[str] = []
|
||||||
|
for rule in re.finditer(r"([^{}]+)\{([^{}]*)\}", source, re.DOTALL):
|
||||||
|
if not hidden_declaration.search(rule.group(2)):
|
||||||
|
continue
|
||||||
|
for selector in rule.group(1).split(","):
|
||||||
|
item = re.search(r"\[\s*data-motion-item\s*\]", selector, re.IGNORECASE)
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
if ".motion-ready" not in selector[: item.start()]:
|
||||||
|
findings.append(" ".join(selector.split()))
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def infinite_unscoped_selectors(source: str) -> list[str]:
|
||||||
|
"""Find infinite animation rules that are not limited to loop mode."""
|
||||||
|
infinite_declaration = re.compile(
|
||||||
|
r"(?:animation\s*:[^;}]*\binfinite\b|"
|
||||||
|
r"animation-iteration-count\s*:\s*infinite\b)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
loop_scope = re.compile(
|
||||||
|
r"\[\s*data-motion-mode\s*=\s*['\"]loop['\"]\s*\]",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
findings: list[str] = []
|
||||||
|
for rule in re.finditer(r"([^{}]+)\{([^{}]*)\}", source, re.DOTALL):
|
||||||
|
if not infinite_declaration.search(rule.group(2)):
|
||||||
|
continue
|
||||||
|
for selector in rule.group(1).split(","):
|
||||||
|
if loop_scope.search(selector) is None:
|
||||||
|
findings.append(" ".join(selector.split()))
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
def canonical_controller() -> str:
|
def canonical_controller() -> str:
|
||||||
parser = parsed_document(MOTION_TEMPLATE.read_text(encoding="utf-8"))
|
parser = parsed_document(MOTION_TEMPLATE.read_text(encoding="utf-8"))
|
||||||
if len(parser.scripts) != 1 or not parser.scripts[0]["closed"]:
|
if len(parser.scripts) != 1 or not parser.scripts[0]["closed"]:
|
||||||
@@ -160,11 +289,12 @@ def verify(path: Path) -> list[str]:
|
|||||||
mode = root.get("data-motion-mode", "")
|
mode = root.get("data-motion-mode", "")
|
||||||
if mode not in MODES:
|
if mode not in MODES:
|
||||||
errors.append(f"data-motion-mode must be one of {sorted(MODES)}; got {mode!r}")
|
errors.append(f"data-motion-mode must be one of {sorted(MODES)}; got {mode!r}")
|
||||||
try:
|
raw_count = root.get("data-step-count", "")
|
||||||
count = int(root.get("data-step-count", ""))
|
if not ASCII_DECIMAL_RE.fullmatch(raw_count):
|
||||||
except ValueError:
|
|
||||||
count = -1
|
count = -1
|
||||||
errors.append("data-step-count must be an integer")
|
errors.append("data-step-count must be an ASCII decimal integer")
|
||||||
|
else:
|
||||||
|
count = int(raw_count)
|
||||||
minimum_count = 0 if mode == "none" else 1
|
minimum_count = 0 if mode == "none" else 1
|
||||||
if count < minimum_count or count > 8:
|
if count < minimum_count or count > 8:
|
||||||
errors.append(f"semantic step count must be {minimum_count}..8; got {count}")
|
errors.append(f"semantic step count must be {minimum_count}..8; got {count}")
|
||||||
@@ -174,11 +304,13 @@ def verify(path: Path) -> list[str]:
|
|||||||
steps: list[int] = []
|
steps: list[int] = []
|
||||||
semantic_steps: list[int] = []
|
semantic_steps: list[int] = []
|
||||||
for index, item in enumerate(parser.items, 1):
|
for index, item in enumerate(parser.items, 1):
|
||||||
try:
|
raw_step = item.get("data-step", "")
|
||||||
step = int(item.get("data-step", ""))
|
if not ASCII_DECIMAL_RE.fullmatch(raw_step):
|
||||||
except ValueError:
|
errors.append(
|
||||||
errors.append(f"motion item {index} has a non-integer data-step")
|
f"motion item {index} has a non-ASCII-decimal data-step"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
step = int(raw_step)
|
||||||
steps.append(step)
|
steps.append(step)
|
||||||
decorative = "data-motion-decorative" in item
|
decorative = "data-motion-decorative" in item
|
||||||
if not decorative:
|
if not decorative:
|
||||||
@@ -214,6 +346,10 @@ def verify(path: Path) -> list[str]:
|
|||||||
status = parser.statuses[0]
|
status = parser.statuses[0]
|
||||||
if status.get("role") != "status" or status.get("aria-live") != "polite" or status.get("aria-atomic") != "true":
|
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")
|
errors.append("motion status needs role=status, aria-live=polite, aria-atomic=true")
|
||||||
|
if parser.statuses_in_controls:
|
||||||
|
errors.append(
|
||||||
|
"motion status must be outside data-motion-controls so hidden controls do not hide live announcements"
|
||||||
|
)
|
||||||
if not parser.scripts:
|
if not parser.scripts:
|
||||||
errors.append("controlled mode needs the scoped control script")
|
errors.append("controlled mode needs the scoped control script")
|
||||||
|
|
||||||
@@ -248,20 +384,52 @@ def verify(path: Path) -> list[str]:
|
|||||||
f"script {number} must exactly match the controller in template-motion.html"
|
f"script {number} must exactly match the controller in template-motion.html"
|
||||||
)
|
)
|
||||||
|
|
||||||
required_source = {
|
script_source = parsed_bodies(parser.scripts)
|
||||||
"prefers-reduced-motion": "reduced-motion CSS fallback",
|
style_source = parsed_bodies(parser.styles)
|
||||||
"@media print": "print CSS fallback",
|
reduced_motion_css = "\n".join(
|
||||||
|
css_at_rule_blocks(
|
||||||
|
style_source,
|
||||||
|
r"@media\s*\(\s*prefers-reduced-motion\s*:\s*reduce\s*\)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print_css = css_at_rule_blocks(style_source, r"@media\s+print\b")
|
||||||
|
|
||||||
|
if not reduced_motion_css:
|
||||||
|
errors.append("missing reduced-motion CSS fallback (prefers-reduced-motion)")
|
||||||
|
if not print_css:
|
||||||
|
errors.append("missing print CSS fallback (@media print)")
|
||||||
|
|
||||||
|
required_css = {
|
||||||
|
r"\[data-motion-controls\]\s*\{[^{}]*display\s*:\s*none\s*!important": (
|
||||||
|
reduced_motion_css,
|
||||||
|
"reduced-motion hidden controls",
|
||||||
|
"[data-motion-controls] { display: none !important; }",
|
||||||
|
),
|
||||||
|
r"\[data-motion-controls\]\[hidden\]\s*\{[^{}]*display\s*:\s*none\s*!important": (
|
||||||
|
style_source,
|
||||||
|
"hidden control override",
|
||||||
|
"[data-motion-controls][hidden] { display: none !important; }",
|
||||||
|
),
|
||||||
|
r"html\[data-motion\s*=\s*['\"]static['\"]\]": (
|
||||||
|
style_source,
|
||||||
|
"deterministic static override",
|
||||||
|
'data-motion="static"',
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if parser.scripts:
|
if parser.scripts:
|
||||||
required_source.update(
|
for pattern, (scope, label, example) in required_css.items():
|
||||||
|
if re.search(pattern, scope, re.IGNORECASE | re.DOTALL) is None:
|
||||||
|
errors.append(f"missing {label} ({example})")
|
||||||
|
|
||||||
|
required_script: dict[str, str] = {}
|
||||||
|
if parser.scripts:
|
||||||
|
required_script.update(
|
||||||
{
|
{
|
||||||
"[data-motion-controls] { display: none !important; }": "reduced-motion hidden controls",
|
|
||||||
"[data-motion-controls][hidden] { display: none !important; }": "hidden control override",
|
|
||||||
'data-motion="static"': "deterministic static override",
|
|
||||||
"motion') === 'step'": "deterministic step override",
|
"motion') === 'step'": "deterministic step override",
|
||||||
"document.fonts": "font-stable screenshot hook",
|
"document.fonts": "font-stable screenshot hook",
|
||||||
"visibilitychange": "background-tab pause",
|
"visibilitychange": "background-tab pause",
|
||||||
"ArrowRight": "keyboard stepping",
|
"ArrowRight": "keyboard stepping",
|
||||||
|
"!event.ctrlKey && !event.metaKey && !event.altKey": "modified-shortcut guard",
|
||||||
"motion-ready": "progressive enhancement class",
|
"motion-ready": "progressive enhancement class",
|
||||||
"requestedStep !== null": "non-null test frame validation",
|
"requestedStep !== null": "non-null test frame validation",
|
||||||
"/^\\d+$/.test(requestedStep)": "non-negative decimal test frame validation",
|
"/^\\d+$/.test(requestedStep)": "non-negative decimal test frame validation",
|
||||||
@@ -271,14 +439,31 @@ def verify(path: Path) -> list[str]:
|
|||||||
"playback controls unavailable": "static/reduced-motion status",
|
"playback controls unavailable": "static/reduced-motion status",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
for needle, label in required_source.items():
|
for needle, label in required_script.items():
|
||||||
if needle not in source:
|
if needle not in script_source:
|
||||||
errors.append(f"missing {label} ({needle})")
|
errors.append(f"missing {label} ({needle})")
|
||||||
|
ready_position = script_source.find("root.classList.add('motion-ready');")
|
||||||
|
initialization_position = script_source.find("if (staticOverride)")
|
||||||
|
if (
|
||||||
|
parser.scripts
|
||||||
|
and ready_position >= 0
|
||||||
|
and initialization_position >= 0
|
||||||
|
and ready_position < initialization_position
|
||||||
|
):
|
||||||
|
errors.append("motion-ready must be added only after the initial render succeeds")
|
||||||
|
|
||||||
if re.search(r"(?<!motion-ready)\s\[data-motion-item\]\s*\{[^}]*opacity\s*:\s*0", source, re.S):
|
unscoped_selectors = hidden_unscoped_motion_selectors(style_source)
|
||||||
errors.append("unscoped data-motion-item opacity:0 breaks the no-JS fallback")
|
if unscoped_selectors:
|
||||||
if mode != "loop" and re.search(r"animation\s*:[^;}]*\binfinite\b", source):
|
errors.append(
|
||||||
errors.append("only loop mode may declare an infinite animation shorthand")
|
"unscoped data-motion-item hiding breaks the no-JS fallback: "
|
||||||
|
+ ", ".join(unscoped_selectors)
|
||||||
|
)
|
||||||
|
infinite_selectors = infinite_unscoped_selectors(style_source)
|
||||||
|
if infinite_selectors:
|
||||||
|
errors.append(
|
||||||
|
"infinite animation must be scoped to data-motion-mode=loop: "
|
||||||
|
+ ", ".join(infinite_selectors)
|
||||||
|
)
|
||||||
if mode == "loop" and len(parser.items) > 2:
|
if mode == "loop" and len(parser.items) > 2:
|
||||||
errors.append("loop mode may contain at most one semantic item and one decorative token")
|
errors.append("loop mode may contain at most one semantic item and one decorative token")
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ SKILL = ROOT / "skills/diagram-design/SKILL.md"
|
|||||||
PATTERNS = ROOT / "skills/diagram-design/references/semantic-patterns.md"
|
PATTERNS = ROOT / "skills/diagram-design/references/semantic-patterns.md"
|
||||||
ANIMATION = ROOT / "skills/diagram-design/references/animation.md"
|
ANIMATION = ROOT / "skills/diagram-design/references/animation.md"
|
||||||
EXAMPLE = ROOT / "skills/diagram-design/assets/example-policy-trace-animated.html"
|
EXAMPLE = ROOT / "skills/diagram-design/assets/example-policy-trace-animated.html"
|
||||||
MAX_SKILL_BYTES = 35_000
|
MAX_SKILL_BYTES = 40_000
|
||||||
|
|
||||||
PATTERN_NAMES = (
|
PATTERN_NAMES = (
|
||||||
"Fan-in queue / bottleneck",
|
"Fan-in queue / bottleneck",
|
||||||
@@ -190,7 +190,13 @@ def verify_markdown() -> list[str]:
|
|||||||
)
|
)
|
||||||
if "Selection: semantic pattern, then visual type" not in skill:
|
if "Selection: semantic pattern, then visual type" not in skill:
|
||||||
errors.append("SKILL.md must choose semantic pattern before visual type")
|
errors.append("SKILL.md must choose semantic pattern before visual type")
|
||||||
if skill.find("semantic-patterns.md") > skill.find("### Visual-type guide (27)"):
|
router_position = skill.find("semantic-patterns.md")
|
||||||
|
guide_position = skill.find("### Visual-type guide (27)")
|
||||||
|
if router_position < 0:
|
||||||
|
errors.append("SKILL.md must link to semantic-patterns.md")
|
||||||
|
if guide_position < 0:
|
||||||
|
errors.append("SKILL.md must contain the 27-row visual-type guide")
|
||||||
|
if router_position >= 0 and guide_position >= 0 and router_position > guide_position:
|
||||||
errors.append("semantic-pattern router must precede the visual-type guide")
|
errors.append("semantic-pattern router must precede the visual-type guide")
|
||||||
|
|
||||||
visual_guide = section(skill, "### Visual-type guide (27)", "Rules of thumb:")
|
visual_guide = section(skill, "### Visual-type guide (27)", "Rules of thumb:")
|
||||||
@@ -324,9 +330,17 @@ def verify_example(path: Path = EXAMPLE) -> list[str]:
|
|||||||
steps: list[int] = []
|
steps: list[int] = []
|
||||||
for item in parser.motion_items:
|
for item in parser.motion_items:
|
||||||
try:
|
try:
|
||||||
steps.append(int(item.get("data-step", "")))
|
step = int(item.get("data-step", ""))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
errors.append("every motion item needs an integer data-step")
|
errors.append("every motion item needs an integer data-step")
|
||||||
|
continue
|
||||||
|
if "data-motion-decorative" in item:
|
||||||
|
if item.get("aria-hidden") != "true" or item.get("focusable") != "false":
|
||||||
|
errors.append(
|
||||||
|
"decorative motion items need aria-hidden=true and focusable=false"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
steps.append(step)
|
||||||
if not item.get("aria-label", "").strip():
|
if not item.get("aria-label", "").strip():
|
||||||
errors.append("every semantic motion item needs an aria-label")
|
errors.append("every semantic motion item needs an aria-label")
|
||||||
if steps != [1, 2, 3, 4, 5]:
|
if steps != [1, 2, 3, 4, 5]:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: diagram-design
|
name: diagram-design
|
||||||
description: Create branded technical and product diagrams in 27 visual types, with semantic patterns, optional accessible motion, and .drawio/Mermaid import.
|
description: Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.
|
||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
version: "2.3"
|
version: "2.3"
|
||||||
@@ -18,6 +18,8 @@ Twenty-seven visual types. Semantic patterns describe behavior independently; ty
|
|||||||
|
|
||||||
**Before generating your first diagram in a new project, verify the style guide has been customized.**
|
**Before generating your first diagram in a new project, verify the style guide has been customized.**
|
||||||
|
|
||||||
|
Don't silently ship default-skinned diagrams into a branded project.
|
||||||
|
|
||||||
Open [`references/style-guide.md`](references/style-guide.md) and check the default tokens. If they're still the shipped defaults (paper `#faf7f2`, ink `#1c1917`, accent `#b5523a` rust), **pause and ask the user**:
|
Open [`references/style-guide.md`](references/style-guide.md) and check the default tokens. If they're still the shipped defaults (paper `#faf7f2`, ink `#1c1917`, accent `#b5523a` rust), **pause and ask the user**:
|
||||||
|
|
||||||
> *"This is your first Schematic in this project. The style guide is still at the default (neutral stone + rust). Do you want to customize it to match your brand first? Options: (a) pull from your website URL, (b) extract from an installed skill, (c) extract from a local folder / design-system directory, (d) paste tokens manually, (e) proceed with the default for now."*
|
> *"This is your first Schematic in this project. The style guide is still at the default (neutral stone + rust). Do you want to customize it to match your brand first? Options: (a) pull from your website URL, (b) extract from an installed skill, (c) extract from a local folder / design-system directory, (d) paste tokens manually, (e) proceed with the default for now."*
|
||||||
@@ -51,7 +53,7 @@ Applied to schematics:
|
|||||||
|
|
||||||
## 2. When to Use
|
## 2. When to Use
|
||||||
|
|
||||||
Use for any of the 27 diagram types (§3) when a reader will learn more from a visual than from prose, a table, or a bulleted list.
|
Use for any of the 27 visual types (§3) when a reader will learn more from a visual than from prose, a table, or a bulleted list.
|
||||||
|
|
||||||
**Don't use for:**
|
**Don't use for:**
|
||||||
|
|
||||||
@@ -457,7 +459,7 @@ Run before producing any diagram.
|
|||||||
- [ ] No vertical `writing-mode` text?
|
- [ ] No vertical `writing-mode` text?
|
||||||
- [ ] `viewBox` expanded for the legend strip (~60px)?
|
- [ ] `viewBox` expanded for the legend strip (~60px)?
|
||||||
- [ ] Every font size, coord, width, height, gap divisible by 4?
|
- [ ] 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, is the controller copied verbatim from `template-motion.html`, and did I run `verify-motion.py --shipped` plus the skin linter?
|
- [ ] 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.
|
||||||
|
|
||||||
**Typography:**
|
**Typography:**
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
--motion-fast: 160ms;
|
--motion-fast: 160ms;
|
||||||
--motion-step: 480ms;
|
--motion-step: 480ms;
|
||||||
--motion-hold: 720ms;
|
--motion-hold: 720ms;
|
||||||
|
--motion-total: 3600ms;
|
||||||
--motion-ease: cubic-bezier(.2,.8,.2,1);
|
--motion-ease: cubic-bezier(.2,.8,.2,1);
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@@ -103,10 +104,10 @@
|
|||||||
font: 500 9px/1.4 var(--mono);
|
font: 500 9px/1.4 var(--mono);
|
||||||
}
|
}
|
||||||
[data-motion-status] {
|
[data-motion-status] {
|
||||||
margin-left: auto;
|
display: block;
|
||||||
min-width: 272px;
|
min-width: 272px;
|
||||||
padding-left: 16px;
|
min-height: 16px;
|
||||||
border-left: 1px solid var(--rule);
|
margin: 8px 0 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font: 500 10px/1.4 var(--mono);
|
font: 500 10px/1.4 var(--mono);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -143,6 +144,12 @@
|
|||||||
transform: none !important;
|
transform: none !important;
|
||||||
transition: none !important;
|
transition: none !important;
|
||||||
}
|
}
|
||||||
|
html[data-motion="step"] [data-motion-controls],
|
||||||
|
html[data-motion="step"] [data-motion-decorative] { display: none !important; }
|
||||||
|
html[data-motion="step"] [data-motion-item] {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
@@ -271,15 +278,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-motion-controls aria-label="Policy trace playback controls">
|
<div data-motion-controls role="group" aria-label="Policy trace playback controls">
|
||||||
<button type="button" data-motion-action="prev">Previous</button>
|
<button type="button" data-motion-action="prev">Previous</button>
|
||||||
<button type="button" data-motion-action="play" aria-pressed="false">Play</button>
|
<button type="button" data-motion-action="play" aria-pressed="false">Play</button>
|
||||||
<button type="button" data-motion-action="pause" aria-pressed="true">Pause</button>
|
<button type="button" data-motion-action="pause" aria-pressed="true">Pause</button>
|
||||||
<button type="button" data-motion-action="next">Next</button>
|
<button type="button" data-motion-action="next">Next</button>
|
||||||
<button type="button" data-motion-action="replay">Replay</button>
|
<button type="button" data-motion-action="replay">Replay</button>
|
||||||
<span class="keyboard-help">←/→ step · Space play/pause · R replay · Home reset</span>
|
<span class="keyboard-help">←/→ step · Space play/pause · R replay · Home/End jump</span>
|
||||||
<span data-motion-status role="status" aria-live="polite" aria-atomic="true">Static frame · all 5 rules visible</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span data-motion-status role="status" aria-live="polite" aria-atomic="true">Static frame · all 5 rules visible</span>
|
||||||
<noscript><p>Animation controls require JavaScript. The complete final policy trace is shown above.</p></noscript>
|
<noscript><p>Animation controls require JavaScript. The complete final policy trace is shown above.</p></noscript>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -293,6 +300,7 @@
|
|||||||
|
|
||||||
document.querySelectorAll('[data-motion-root]').forEach((root) => {
|
document.querySelectorAll('[data-motion-root]').forEach((root) => {
|
||||||
const count = Number(root.dataset.stepCount);
|
const count = Number(root.dataset.stepCount);
|
||||||
|
const hold = parseFloat(getComputedStyle(root).getPropertyValue('--motion-hold')) || 720;
|
||||||
const parsedStep = requestedStep !== null && /^\d+$/.test(requestedStep) ? Number(requestedStep) : null;
|
const parsedStep = requestedStep !== null && /^\d+$/.test(requestedStep) ? Number(requestedStep) : null;
|
||||||
const exactStep = params.get('motion') === 'step' && Number.isSafeInteger(parsedStep) && parsedStep >= 0 && parsedStep <= count ? parsedStep : null;
|
const exactStep = params.get('motion') === 'step' && Number.isSafeInteger(parsedStep) && parsedStep >= 0 && parsedStep <= count ? parsedStep : null;
|
||||||
const items = [...root.querySelectorAll('[data-motion-item]')];
|
const items = [...root.querySelectorAll('[data-motion-item]')];
|
||||||
@@ -355,7 +363,7 @@
|
|||||||
if (step >= count) { finish(); return; }
|
if (step >= count) { finish(); return; }
|
||||||
render(step + 1, false);
|
render(step + 1, false);
|
||||||
if (step >= count) { finish(); return; }
|
if (step >= count) { finish(); return; }
|
||||||
timer = window.setTimeout(tick, 720);
|
timer = window.setTimeout(tick, hold);
|
||||||
}
|
}
|
||||||
|
|
||||||
function play(fromStart = false, userInitiated = false) {
|
function play(fromStart = false, userInitiated = false) {
|
||||||
@@ -365,7 +373,7 @@
|
|||||||
root.classList.add('is-playing');
|
root.classList.add('is-playing');
|
||||||
buttons('play').setAttribute('aria-pressed', 'true');
|
buttons('play').setAttribute('aria-pressed', 'true');
|
||||||
buttons('pause').setAttribute('aria-pressed', 'false');
|
buttons('pause').setAttribute('aria-pressed', 'false');
|
||||||
timer = window.setTimeout(tick, 720);
|
timer = window.setTimeout(tick, hold);
|
||||||
}
|
}
|
||||||
|
|
||||||
controls.addEventListener('click', (event) => {
|
controls.addEventListener('click', (event) => {
|
||||||
@@ -387,7 +395,7 @@
|
|||||||
playing ? pause(true) : play(false, true);
|
playing ? pause(true) : play(false, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key.toLowerCase() === 'r') {
|
if (!event.ctrlKey && !event.metaKey && !event.altKey && event.key.toLowerCase() === 'r') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
play(true, true);
|
play(true, true);
|
||||||
return;
|
return;
|
||||||
@@ -439,7 +447,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
root.classList.add('motion-ready');
|
|
||||||
if (staticOverride) {
|
if (staticOverride) {
|
||||||
document.documentElement.dataset.motion = 'static';
|
document.documentElement.dataset.motion = 'static';
|
||||||
pause(false);
|
pause(false);
|
||||||
@@ -475,6 +482,7 @@
|
|||||||
render(0, false);
|
render(0, false);
|
||||||
status.textContent = `Ready · step 0 of ${count}`;
|
status.textContent = `Ready · step 0 of ${count}`;
|
||||||
}
|
}
|
||||||
|
root.classList.add('motion-ready');
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
--motion-fast: 160ms;
|
--motion-fast: 160ms;
|
||||||
--motion-step: 480ms;
|
--motion-step: 480ms;
|
||||||
--motion-hold: 720ms;
|
--motion-hold: 720ms;
|
||||||
--motion-total: 4320ms;
|
--motion-total: 3600ms;
|
||||||
--motion-ease: cubic-bezier(.2,.8,.2,1);
|
--motion-ease: cubic-bezier(.2,.8,.2,1);
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
/* Static source is complete. Only an initialized enhancement hides items. */
|
/* Static source is complete. Only an initialized enhancement hides items. */
|
||||||
.motion-ready [data-motion-item] {
|
.motion-ready [data-motion-item] {
|
||||||
opacity: 0;
|
opacity: .12;
|
||||||
transform: translateY(8px);
|
transform: translateY(8px);
|
||||||
transform-box: fill-box;
|
transform-box: fill-box;
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
@@ -128,6 +128,11 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
noscript p {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font: 500 12px/1.4 var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
html[data-motion="static"] [data-motion-controls],
|
html[data-motion="static"] [data-motion-controls],
|
||||||
html[data-motion="static"] [data-motion-decorative] { display: none !important; }
|
html[data-motion="static"] [data-motion-decorative] { display: none !important; }
|
||||||
@@ -216,16 +221,17 @@
|
|||||||
<circle data-motion-item data-step="3" data-motion-decorative aria-hidden="true" focusable="false" class="flow-token" cx="556" cy="180" r="6" fill="#eb6c36"/>
|
<circle data-motion-item data-step="3" data-motion-decorative aria-hidden="true" focusable="false" class="flow-token" cx="556" cy="180" r="6" fill="#eb6c36"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<div data-motion-controls aria-label="Diagram playback controls">
|
<div data-motion-controls role="group" aria-label="Diagram playback controls">
|
||||||
<button type="button" data-motion-action="prev">Previous</button>
|
<button type="button" data-motion-action="prev">Previous</button>
|
||||||
<button type="button" data-motion-action="play" aria-pressed="false">Play</button>
|
<button type="button" data-motion-action="play" aria-pressed="false">Play</button>
|
||||||
<button type="button" data-motion-action="pause" aria-pressed="true">Pause</button>
|
<button type="button" data-motion-action="pause" aria-pressed="true">Pause</button>
|
||||||
<button type="button" data-motion-action="next">Next</button>
|
<button type="button" data-motion-action="next">Next</button>
|
||||||
<button type="button" data-motion-action="replay">Replay</button>
|
<button type="button" data-motion-action="replay">Replay</button>
|
||||||
<span class="keyboard-help">Arrow keys step · Home/End jump</span>
|
<span class="keyboard-help">←/→ step · Space play/pause · R replay · Home/End jump</span>
|
||||||
<span data-motion-status-visible aria-hidden="true">Step <span data-motion-step-label>5</span> of 5</span>
|
<span data-motion-status-visible aria-hidden="true">Step <span data-motion-step-label>5</span> of 5</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="sr-only" data-motion-status role="status" aria-live="polite" aria-atomic="true"></p>
|
<p class="sr-only" data-motion-status role="status" aria-live="polite" aria-atomic="true"></p>
|
||||||
|
<noscript><p>Animation controls require JavaScript. The complete final diagram is shown above.</p></noscript>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script data-diagram-controls>
|
<script data-diagram-controls>
|
||||||
@@ -238,6 +244,7 @@
|
|||||||
|
|
||||||
document.querySelectorAll('[data-motion-root]').forEach((root) => {
|
document.querySelectorAll('[data-motion-root]').forEach((root) => {
|
||||||
const count = Number(root.dataset.stepCount);
|
const count = Number(root.dataset.stepCount);
|
||||||
|
const hold = parseFloat(getComputedStyle(root).getPropertyValue('--motion-hold')) || 720;
|
||||||
const parsedStep = requestedStep !== null && /^\d+$/.test(requestedStep) ? Number(requestedStep) : null;
|
const parsedStep = requestedStep !== null && /^\d+$/.test(requestedStep) ? Number(requestedStep) : null;
|
||||||
const exactStep = params.get('motion') === 'step' && Number.isSafeInteger(parsedStep) && parsedStep >= 0 && parsedStep <= count ? parsedStep : null;
|
const exactStep = params.get('motion') === 'step' && Number.isSafeInteger(parsedStep) && parsedStep >= 0 && parsedStep <= count ? parsedStep : null;
|
||||||
const items = [...root.querySelectorAll('[data-motion-item]')];
|
const items = [...root.querySelectorAll('[data-motion-item]')];
|
||||||
@@ -300,7 +307,7 @@
|
|||||||
if (step >= count) { finish(); return; }
|
if (step >= count) { finish(); return; }
|
||||||
render(step + 1, false);
|
render(step + 1, false);
|
||||||
if (step >= count) { finish(); return; }
|
if (step >= count) { finish(); return; }
|
||||||
timer = window.setTimeout(tick, 720);
|
timer = window.setTimeout(tick, hold);
|
||||||
}
|
}
|
||||||
|
|
||||||
function play(fromStart = false, userInitiated = false) {
|
function play(fromStart = false, userInitiated = false) {
|
||||||
@@ -310,7 +317,7 @@
|
|||||||
root.classList.add('is-playing');
|
root.classList.add('is-playing');
|
||||||
buttons('play').setAttribute('aria-pressed', 'true');
|
buttons('play').setAttribute('aria-pressed', 'true');
|
||||||
buttons('pause').setAttribute('aria-pressed', 'false');
|
buttons('pause').setAttribute('aria-pressed', 'false');
|
||||||
timer = window.setTimeout(tick, 720);
|
timer = window.setTimeout(tick, hold);
|
||||||
}
|
}
|
||||||
|
|
||||||
controls.addEventListener('click', (event) => {
|
controls.addEventListener('click', (event) => {
|
||||||
@@ -332,7 +339,7 @@
|
|||||||
playing ? pause(true) : play(false, true);
|
playing ? pause(true) : play(false, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key.toLowerCase() === 'r') {
|
if (!event.ctrlKey && !event.metaKey && !event.altKey && event.key.toLowerCase() === 'r') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
play(true, true);
|
play(true, true);
|
||||||
return;
|
return;
|
||||||
@@ -384,7 +391,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
root.classList.add('motion-ready');
|
|
||||||
if (staticOverride) {
|
if (staticOverride) {
|
||||||
document.documentElement.dataset.motion = 'static';
|
document.documentElement.dataset.motion = 'static';
|
||||||
pause(false);
|
pause(false);
|
||||||
@@ -420,6 +426,7 @@
|
|||||||
render(0, false);
|
render(0, false);
|
||||||
status.textContent = `Ready · step 0 of ${count}`;
|
status.textContent = `Ready · step 0 of ${count}`;
|
||||||
}
|
}
|
||||||
|
root.classList.add('motion-ready');
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ Choose one mode per figure with `data-motion-mode="none|reveal|step|loop"`.
|
|||||||
| Mode | Behavior | Controls / implementation | Use |
|
| Mode | Behavior | Controls / implementation | Use |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `none` | Complete stable figure | No JavaScript | Default, print, screenshot, export, reduced-motion fallback with playback controls unavailable |
|
| `none` | Complete stable figure | No JavaScript | Default, print, screenshot, export, reduced-motion fallback with playback controls unavailable |
|
||||||
| `reveal` | One deterministic run ending complete | CSS-only for ≤5s; otherwise use the scoped controller | Short ordered explanation; never auto-replay |
|
| `reveal` | One deterministic autoplay run ending complete | CSS-only for ≤5s; otherwise use the scoped controller | Short ordered explanation; never auto-replay |
|
||||||
| `step` | Paused semantic states | Minimal inline JS for Play, Pause, Replay, Previous, Next | Teaching, comparison, policy traces |
|
| `step` | Paused semantic states | Minimal inline JS for Play, Pause, Replay, Previous, Next | Teaching, comparison, policy traces |
|
||||||
| `loop` | One decorative token repeats without changing meaning | CSS-only default | Quiet flow hint; ≥3s cycle |
|
| `loop` | One decorative token repeats without changing meaning | CSS-only default | Quiet flow hint; ≥3s cycle |
|
||||||
|
|
||||||
Only `loop` repeats. Queue state, typing, field values, policy outcomes, containment, and audit entries use `reveal` or `step` and finish complete.
|
Only `loop` repeats. Queue state, typing, field values, policy outcomes, containment, and audit entries use `reveal` or `step` and finish complete.
|
||||||
|
|
||||||
|
`reveal` is the sole sanctioned autoplay mode: it may run once on initial load when motion was explicitly requested, then remains complete. It never restarts on viewport re-entry or without an explicit Replay action.
|
||||||
|
|
||||||
## Static-first enhancement contract
|
## Static-first enhancement contract
|
||||||
|
|
||||||
1. **Source is complete.** Every semantic node, label, connector, status, and outcome is visible in the HTML/SVG before enhancement. Only selectors below `.motion-ready` may hide or transform them.
|
1. **Source is complete.** Every semantic node, label, connector, status, and outcome is visible in the HTML/SVG before enhancement. Only selectors below `.motion-ready` may hide or transform them.
|
||||||
@@ -24,7 +26,7 @@ Only `loop` repeats. Queue state, typing, field values, policy outcomes, contain
|
|||||||
5. **Explicit order.** Mark items `data-motion-item data-step="N"` for integer steps 1–8. DOM order follows narrative order. At most two items enter per step.
|
5. **Explicit order.** Mark items `data-motion-item data-step="N"` for integer steps 1–8. DOM order follows narrative order. At most two items enter per step.
|
||||||
6. **Stable end.** Completion exposes all items and sets `data-frame="end"`. Replay resets to step 0 first. Pause clears the pending timer and resume continues from the same step.
|
6. **Stable end.** Completion exposes all items and sets `data-frame="end"`. Replay resets to step 0 first. Pause clears the pending timer and resume continues from the same step.
|
||||||
7. **Scoped state.** Controls operate on their nearest `[data-motion-root]`; IDs, timers, live regions, and step state never cross figure boundaries.
|
7. **Scoped state.** Controls operate on their nearest `[data-motion-root]`; IDs, timers, live regions, and step state never cross figure boundaries.
|
||||||
8. **Failure-safe startup.** JavaScript adds `.motion-ready` only after controls are bound. A script error before that point leaves the complete source visible.
|
8. **Failure-safe startup.** JavaScript adds `.motion-ready` only after controls are bound and the initial render succeeds. A script error before that point leaves the complete source visible.
|
||||||
|
|
||||||
## Semantic primitives
|
## Semantic primitives
|
||||||
|
|
||||||
@@ -48,10 +50,11 @@ Do not animate layout coordinates, connector routes, `viewBox`, node dimensions,
|
|||||||
--motion-fast: 160ms;
|
--motion-fast: 160ms;
|
||||||
--motion-step: 480ms;
|
--motion-step: 480ms;
|
||||||
--motion-hold: 720ms;
|
--motion-hold: 720ms;
|
||||||
|
--motion-total: 3600ms; /* five steps × hold; set this per diagram */
|
||||||
--motion-ease: cubic-bezier(.2,.8,.2,1);
|
--motion-ease: cubic-bezier(.2,.8,.2,1);
|
||||||
}
|
}
|
||||||
.motion-ready [data-motion-item] {
|
.motion-ready [data-motion-item] {
|
||||||
opacity: 0;
|
opacity: .12;
|
||||||
transform: translateY(8px);
|
transform: translateY(8px);
|
||||||
transition: opacity var(--motion-step) var(--motion-ease),
|
transition: opacity var(--motion-step) var(--motion-ease),
|
||||||
transform var(--motion-step) var(--motion-ease);
|
transform var(--motion-step) var(--motion-ease);
|
||||||
@@ -83,9 +86,9 @@ Do not animate layout coordinates, connector routes, `viewBox`, node dimensions,
|
|||||||
|
|
||||||
Every interactive `step` figure provides native buttons for **Play, Pause, Replay, Previous, and Next**, outside the SVG. Use `data-motion-action="play|pause|replay|prev|next"`, ≥44×44px targets, visible focus, disabled state for unavailable actions, and `aria-pressed` for play/pause state.
|
Every interactive `step` figure provides native buttons for **Play, Pause, Replay, Previous, and Next**, outside the SVG. Use `data-motion-action="play|pause|replay|prev|next"`, ≥44×44px targets, visible focus, disabled state for unavailable actions, and `aria-pressed` for play/pause state.
|
||||||
|
|
||||||
When focus is within the motion root: `ArrowRight` advances, `ArrowLeft` goes back, `Home` resets, `End` completes, and `Space` toggles play/pause when focus is not already on a native control. Do not capture keys from inputs, links, or unrelated regions. Never move focus as the frame changes.
|
When focus is within the motion root: `ArrowRight` advances, `ArrowLeft` goes back, `Home` resets, `End` completes, `Space` toggles play/pause when focus is not already on a native control, and unmodified `R` replays. Never intercept `R` when Control, Command, or Alt is held. Do not capture keys from inputs, links, or unrelated regions. Never move focus as the frame changes.
|
||||||
|
|
||||||
Provide visible instructions and a scoped `role="status" aria-live="polite" aria-atomic="true"`. Announce user actions such as “Step 3 of 5: first divergence”; do not announce every autoplay frame. Controls operate only on their nearest `[data-motion-root]`.
|
Provide visible instructions and a scoped `role="status" aria-live="polite" aria-atomic="true"`. Keep that live region inside the motion root but outside `[data-motion-controls]`, so hiding controls for reduced/static states cannot hide announcements. Announce user actions such as “Step 3 of 5: first divergence”; do not announce every autoplay frame. Controls operate only on their nearest `[data-motion-root]`.
|
||||||
|
|
||||||
Use [`assets/template-motion.html`](../assets/template-motion.html) rather than inventing another controller. Its inline controller is the executable implementation contract: copy that script body verbatim. The skin linter rejects modified or additional controllers, even when they carry `data-diagram-controls`. Replace diagram content and slug-prefixed IDs, but preserve the controller and its state/control attributes.
|
Use [`assets/template-motion.html`](../assets/template-motion.html) rather than inventing another controller. Its inline controller is the executable implementation contract: copy that script body verbatim. The skin linter rejects modified or additional controllers, even when they carry `data-diagram-controls`. Replace diagram content and slug-prefixed IDs, but preserve the controller and its state/control attributes.
|
||||||
|
|
||||||
@@ -101,7 +104,7 @@ Use [`assets/template-motion.html`](../assets/template-motion.html) rather than
|
|||||||
|
|
||||||
Motion does not raise the static diagram budget: ≤8 semantic steps (target 3–6), ≤12 marked items, ≤2 simultaneous reveals, ≤2 drawn paths, one flow-token loop, 160–600ms transitions, 400–1200ms holds, ≤24px translation, and 3–8s total autoplay.
|
Motion does not raise the static diagram budget: ≤8 semantic steps (target 3–6), ≤12 marked items, ≤2 simultaneous reveals, ≤2 drawn paths, one flow-token loop, 160–600ms transitions, 400–1200ms holds, ≤24px translation, and 3–8s total autoplay.
|
||||||
|
|
||||||
Declare `data-step-count`; do not infer steps from transition events. Use one `setTimeout` chain per root, clear it on Pause/Replay/page hide and immediately after rendering the final step, and never use `setInterval` for semantic playback. Pause when `document.visibilityState` becomes hidden and do not catch up later. `?motion=step&step=N` may expose an exact zero-duration frame for visual regression only when `N` is a non-negative base-10 integer from 0 through `data-step-count`; missing, fractional, negative, and over-budget values leave normal playback in place.
|
Declare `data-step-count`; do not infer steps from transition events. Set `--motion-total` to step count × `--motion-hold` and keep it within the 8-second budget. Use one `setTimeout` chain per root, derive its hold from `--motion-hold`, clear it on Pause/Replay/page hide and immediately after rendering the final step, and never use `setInterval` for semantic playback. Pause when `document.visibilityState` becomes hidden and do not catch up later. `?motion=step&step=N` may expose an exact zero-duration frame for visual regression only when `N` is a non-negative base-10 integer from 0 through `data-step-count`; missing, fractional, negative, and over-budget values leave normal playback in place.
|
||||||
|
|
||||||
The final-state capture contract is synchronous: `?motion=static`, `<html data-motion="static">`, or mode `none` exposes every semantic item, hides controls and decorative overlays, and sets `data-frame="static"`. Wait for `document.fonts.ready` before capture. Two captures from the same URL, viewport, fonts, and device scale must be pixel-identical; random delays, generated IDs, clocks, and runtime path measurement are forbidden.
|
The final-state capture contract is synchronous: `?motion=static`, `<html data-motion="static">`, or mode `none` exposes every semantic item, hides controls and decorative overlays, and sets `data-frame="static"`. Wait for `document.fonts.ready` before capture. Two captures from the same URL, viewport, fonts, and device scale must be pixel-identical; random delays, generated IDs, clocks, and runtime path measurement are forbidden.
|
||||||
|
|
||||||
@@ -130,7 +133,7 @@ Then verify in a browser:
|
|||||||
|
|
||||||
## Anti-patterns
|
## Anti-patterns
|
||||||
|
|
||||||
- Autoplay on load, viewport re-entry, or an endless semantic loop.
|
- Unrequested autoplay, autoplay outside the single sanctioned `reveal` run, viewport re-entry, or an endless semantic loop.
|
||||||
- A blank/partial no-JS or reduced-motion frame.
|
- A blank/partial no-JS or reduced-motion frame.
|
||||||
- Motion that rescues an over-dense or unlabeled static diagram.
|
- Motion that rescues an over-dense or unlabeled static diagram.
|
||||||
- Pass/fail, queue fullness, or outcome encoded only by hue.
|
- Pass/fail, queue fullness, or outcome encoded only by hue.
|
||||||
|
|||||||
Reference in New Issue
Block a user