[verified] add semantic patterns and accessible motion
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "diagram-design",
|
"name": "diagram-design",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Editorial-quality technical and product diagrams — 27 types rendered as standalone HTML with inline SVG, skinnable to match your brand, plus draw.io and Mermaid import at a chosen format, size, and level of detail"
|
"description": "Branded diagrams across 27 visual types, with semantic system patterns, optional accessible motion, and draw.io or Mermaid import"
|
||||||
},
|
},
|
||||||
"owner": {
|
"owner": {
|
||||||
"name": "Cathryn Lavery"
|
"name": "Cathryn Lavery"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "diagram-design",
|
"name": "diagram-design",
|
||||||
"description": "Editorial-quality technical and product diagrams — 27 types rendered as standalone HTML with inline SVG, skinnable to match your brand, plus draw.io and Mermaid import at a chosen format, size, and level of detail",
|
"description": "Branded diagrams across 27 visual types, with semantic system patterns, optional accessible motion, and draw.io or Mermaid import",
|
||||||
"version": "2.2.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",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
"editorial",
|
"editorial",
|
||||||
"drawio",
|
"drawio",
|
||||||
"mermaid",
|
"mermaid",
|
||||||
"import"
|
"import",
|
||||||
|
"animation",
|
||||||
|
"semantic-patterns"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "diagram-design",
|
"name": "diagram-design",
|
||||||
"description": "Editorial-quality technical and product diagrams - 27 types rendered as standalone HTML with inline SVG, skinnable to match your brand, plus draw.io and Mermaid import at a chosen format, size, and level of detail",
|
"description": "Branded diagrams across 27 visual types, with semantic system patterns, optional accessible motion, and draw.io or Mermaid import",
|
||||||
"version": "2.2.0",
|
"version": "2.3.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Cathryn Lavery",
|
"name": "Cathryn Lavery",
|
||||||
"url": "https://github.com/cathrynlavery"
|
"url": "https://github.com/cathrynlavery"
|
||||||
@@ -18,13 +18,15 @@
|
|||||||
"editorial",
|
"editorial",
|
||||||
"drawio",
|
"drawio",
|
||||||
"mermaid",
|
"mermaid",
|
||||||
"import"
|
"import",
|
||||||
|
"animation",
|
||||||
|
"semantic-patterns"
|
||||||
],
|
],
|
||||||
"skills": "./skills/",
|
"skills": "./skills/",
|
||||||
"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 architecture, flowchart, sequence, state machine, ER, timeline, swimlane, quadrant, nested, tree, layers, venn, and pyramid diagrams with an opinionated editorial design system.",
|
"longDescription": "Generate branded technical and product diagrams with semantic system patterns, optional accessible motion, and an opinionated editorial design system.",
|
||||||
"developerName": "Cathryn Lavery",
|
"developerName": "Cathryn Lavery",
|
||||||
"category": "Productivity",
|
"category": "Productivity",
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
|
|||||||
@@ -40,38 +40,72 @@ jobs:
|
|||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Verify accessible SVG contract
|
- name: Verify accessible SVG contract
|
||||||
|
if: always()
|
||||||
id: a11y
|
id: a11y
|
||||||
run: python scripts/test-lint-a11y.py
|
run: python scripts/test-lint-a11y.py
|
||||||
|
|
||||||
|
- name: Verify semantic pattern documentation
|
||||||
|
if: always()
|
||||||
|
id: semantic_docs
|
||||||
|
run: python scripts/verify-semantic-motion.py --markdown-only
|
||||||
|
|
||||||
|
- name: Verify animated example structure
|
||||||
|
if: always()
|
||||||
|
id: animated_example
|
||||||
|
run: python scripts/verify-semantic-motion.py --example-only
|
||||||
|
|
||||||
|
- name: Lint animated example skin
|
||||||
|
if: always()
|
||||||
|
id: animated_lint
|
||||||
|
run: python scripts/lint-skin.py skills/diagram-design/assets/example-policy-trace-animated.html
|
||||||
|
|
||||||
|
- name: Verify every shipped motion file
|
||||||
|
if: always()
|
||||||
|
id: shipped_motion
|
||||||
|
run: python scripts/verify-motion.py --shipped
|
||||||
|
|
||||||
- name: Verify URL SVG normalization
|
- name: Verify URL SVG normalization
|
||||||
|
if: always()
|
||||||
id: url_icons
|
id: url_icons
|
||||||
run: python scripts/test-build-icons-quoted-attributes.py
|
run: python scripts/test-build-icons-quoted-attributes.py
|
||||||
|
|
||||||
- name: Verify Devicon normalization
|
- name: Verify Devicon normalization
|
||||||
|
if: always()
|
||||||
id: devicon
|
id: devicon
|
||||||
run: python scripts/test-build-icons-devicon.py
|
run: python scripts/test-build-icons-devicon.py
|
||||||
|
|
||||||
- name: Verify mojibake repair utility
|
- name: Verify mojibake repair utility
|
||||||
|
if: always()
|
||||||
id: mojibake
|
id: mojibake
|
||||||
run: python scripts/test-fix-mojibake.py
|
run: python scripts/test-fix-mojibake.py
|
||||||
|
|
||||||
- name: Run skin linter
|
- name: Run skin linter
|
||||||
|
if: always()
|
||||||
id: skin
|
id: skin
|
||||||
run: python scripts/lint-skin.py --all --baseline
|
run: python scripts/lint-skin.py --all --baseline
|
||||||
|
|
||||||
- name: Verify sequence OAuth specs
|
- name: Verify sequence OAuth specs
|
||||||
|
if: always()
|
||||||
id: oauth
|
id: oauth
|
||||||
run: python scripts/verify-sequence-oauth.py
|
run: python scripts/verify-sequence-oauth.py
|
||||||
|
|
||||||
- name: Verify draw.io import script
|
- name: Verify draw.io import script
|
||||||
|
if: always()
|
||||||
id: drawio
|
id: drawio
|
||||||
run: python scripts/verify-drawio-import.py
|
run: python scripts/verify-drawio-import.py
|
||||||
|
|
||||||
- name: Verify Mermaid import script
|
- name: Verify Mermaid import script
|
||||||
|
if: always()
|
||||||
id: mermaid
|
id: mermaid
|
||||||
run: python scripts/verify-mermaid-import.py
|
run: python scripts/verify-mermaid-import.py
|
||||||
|
|
||||||
|
- name: Verify optional motion contract
|
||||||
|
if: always()
|
||||||
|
id: motion
|
||||||
|
run: python scripts/test-verify-motion.py
|
||||||
|
|
||||||
- name: Verify generated icon assets
|
- name: Verify generated icon assets
|
||||||
|
if: always()
|
||||||
id: icons
|
id: icons
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -88,6 +122,10 @@ jobs:
|
|||||||
echo "| Verification Gate | Outcome |" >> $GITHUB_STEP_SUMMARY
|
echo "| Verification Gate | Outcome |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
|
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| SVG Accessibility Contract | ${{ steps.a11y.outcome == 'success' && '✅ Passed' || (steps.a11y.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
echo "| SVG Accessibility Contract | ${{ steps.a11y.outcome == 'success' && '✅ Passed' || (steps.a11y.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Semantic Pattern Documentation | ${{ steps.semantic_docs.outcome == 'success' && '✅ Passed' || (steps.semantic_docs.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Animated Example Structure | ${{ steps.animated_example.outcome == 'success' && '✅ Passed' || (steps.animated_example.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Animated Example Skin | ${{ steps.animated_lint.outcome == 'success' && '✅ Passed' || (steps.animated_lint.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Shipped Motion HTML | ${{ steps.shipped_motion.outcome == 'success' && '✅ Passed' || (steps.shipped_motion.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| URL SVG Normalization | ${{ steps.url_icons.outcome == 'success' && '✅ Passed' || (steps.url_icons.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
echo "| URL SVG Normalization | ${{ steps.url_icons.outcome == 'success' && '✅ Passed' || (steps.url_icons.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| Devicon Normalization | ${{ steps.devicon.outcome == 'success' && '✅ Passed' || (steps.devicon.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
echo "| Devicon Normalization | ${{ steps.devicon.outcome == 'success' && '✅ Passed' || (steps.devicon.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| Mojibake Repair Utility | ${{ steps.mojibake.outcome == 'success' && '✅ Passed' || (steps.mojibake.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
echo "| Mojibake Repair Utility | ${{ steps.mojibake.outcome == 'success' && '✅ Passed' || (steps.mojibake.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -95,4 +133,5 @@ jobs:
|
|||||||
echo "| Sequence Diagram Grammar | ${{ steps.oauth.outcome == 'success' && '✅ Passed' || (steps.oauth.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
echo "| Sequence Diagram Grammar | ${{ steps.oauth.outcome == 'success' && '✅ Passed' || (steps.oauth.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| Draw.io Import Extractor | ${{ steps.drawio.outcome == 'success' && '✅ Passed' || (steps.drawio.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
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 "| 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 "| Primitive Icons & Documentation | ${{ steps.icons.outcome == 'success' && '✅ Passed' || (steps.icons.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
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
.claude/
|
.claude/
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|||||||
+11
-1
@@ -30,21 +30,29 @@ Every validation gate below must pass before a PR is ready. They also run automa
|
|||||||
| What it checks | Command |
|
| What it checks | Command |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 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` |
|
||||||
|
| 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 (colors, fonts, a11y) | `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` |
|
||||||
| Mermaid import path (grammars, adversarial input, caps, docs sync) | `python3 scripts/verify-mermaid-import.py` |
|
| 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` |
|
||||||
| 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 |
|
||||||
|
|
||||||
Run them all at once before pushing:
|
Run them all at once before pushing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/test-lint-a11y.py \
|
python3 scripts/test-lint-a11y.py \
|
||||||
|
&& python3 scripts/verify-semantic-motion.py --markdown-only \
|
||||||
|
&& python3 scripts/verify-semantic-motion.py --example-only \
|
||||||
|
&& python3 scripts/verify-motion.py --shipped \
|
||||||
&& python3 scripts/lint-skin.py --all --baseline \
|
&& python3 scripts/lint-skin.py --all --baseline \
|
||||||
&& python3 scripts/verify-sequence-oauth.py \
|
&& python3 scripts/verify-sequence-oauth.py \
|
||||||
&& python3 scripts/verify-drawio-import.py \
|
&& python3 scripts/verify-drawio-import.py \
|
||||||
&& python3 scripts/verify-mermaid-import.py
|
&& python3 scripts/verify-mermaid-import.py \
|
||||||
|
&& python3 scripts/test-verify-motion.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### If a gate fails
|
### If a gate fails
|
||||||
@@ -86,6 +94,8 @@ 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.
|
||||||
|
|
||||||
## Adding a new diagram type
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
|
|
||||||
*New in 2.0 — the Loop: flywheels with a shared-memory hub. The dashed lines are the write-backs.*
|
*New in 2.0 — the Loop: flywheels with a shared-memory hub. The dashed lines are the write-backs.*
|
||||||
|
|
||||||
27 types. One agent skill for Claude Code, Codex, and Pi. Your brand in 60 seconds — the skill reads your website and maps colors + fonts to every diagram. Already have diagrams in draw.io or Mermaid? Point it at the source and it redraws them at the format, size, and level of detail your audience needs.
|
*New in 2.3: semantic system patterns and optional accessible motion, while static output stays the default.*
|
||||||
|
|
||||||
|
27 visual types. One agent skill for Claude Code, Codex, and Pi. Semantic patterns describe behavior separately from layout, so a queue, policy trace, or trust boundary can use the nearest existing type without expanding the type count. Static HTML remains the default; optional motion is available for ordered explanations. The skill also redraws draw.io or Mermaid sources at a chosen format, size, and detail level.
|
||||||
|
|
||||||
No Figma. No generic rounded boxes. No 30-minute color-picking sessions.
|
No Figma. No generic rounded boxes. No 30-minute color-picking sessions.
|
||||||
|
|
||||||
@@ -26,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 variants: minimal light, minimal dark, and full-editorial. Open any of them directly in a browser — no build step, no JS, no external images.
|
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.
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -149,6 +151,8 @@ You: "yes, apply it"
|
|||||||
|
|
||||||
Every new diagram now uses your colors. Your website's paper color becomes the diagram background. Your CTA color becomes the focal accent. Your body font stack becomes the node label family.
|
Every new diagram now uses your colors. Your website's paper color becomes the diagram background. Your CTA color becomes the focal accent. Your body font stack becomes the node label family.
|
||||||
|
|
||||||
|
Brand matching also emits a fidelity receipt: sampled URLs, exact color roles, font families and weights, font source URLs, and any fallback. Public site fonts are used directly and verified after rendering rather than silently replaced with generic system fonts.
|
||||||
|
|
||||||
### What gets extracted
|
### What gets extracted
|
||||||
|
|
||||||
| Detected from your site | Becomes |
|
| Detected from your site | Becomes |
|
||||||
@@ -206,6 +210,12 @@ cp skills/diagram-design/assets/template.html my-diagram.html # minimal l
|
|||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Import from draw.io or Mermaid
|
## Import from draw.io or Mermaid
|
||||||
@@ -289,7 +299,7 @@ Both formats are diagram-only — editorial cards and headers from `-full` varia
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Progressive disclosure. `SKILL.md` is a lean index — it tells the agent how to pick a type and where to look for detail. Every type lives in its own reference file, loaded only when relevant.
|
Progressive disclosure. `SKILL.md` routes behavior first when needed, then layout. Semantic, type, and animation references load only when relevant.
|
||||||
|
|
||||||
```
|
```
|
||||||
diagram-design/
|
diagram-design/
|
||||||
@@ -305,6 +315,8 @@ diagram-design/
|
|||||||
│ ├── SKILL.md — philosophy, selection guide, checklist
|
│ ├── SKILL.md — philosophy, selection guide, checklist
|
||||||
│ ├── references/ — loaded only when a type or primitive is chosen
|
│ ├── references/ — loaded only when a type or primitive is chosen
|
||||||
│ │ ├── style-guide.md — single source of truth for colors + fonts
|
│ │ ├── style-guide.md — single source of truth for colors + fonts
|
||||||
|
│ │ ├── semantic-patterns.md — behavior patterns independent of layout
|
||||||
|
│ │ ├── animation.md — optional motion + accessibility contract
|
||||||
│ │ ├── onboarding.md — the URL-to-tokens flow
|
│ │ ├── onboarding.md — the URL-to-tokens flow
|
||||||
│ │ ├── import-drawio.md — draw.io redraw procedure
|
│ │ ├── import-drawio.md — draw.io redraw procedure
|
||||||
│ │ ├── import-mermaid.md — Mermaid redraw procedure
|
│ │ ├── import-mermaid.md — Mermaid redraw procedure
|
||||||
@@ -338,6 +350,7 @@ diagram-design/
|
|||||||
│ ├── example-quadrant-consultant.html
|
│ ├── example-quadrant-consultant.html
|
||||||
│ ├── example-import-drawio.html
|
│ ├── example-import-drawio.html
|
||||||
│ ├── example-import-mermaid.html
|
│ ├── example-import-mermaid.html
|
||||||
|
│ ├── example-policy-trace-animated.html
|
||||||
│ └── example-sequence-oauth*.html
|
│ └── example-sequence-oauth*.html
|
||||||
├── scripts/fixtures/
|
├── scripts/fixtures/
|
||||||
│ ├── sample-flowchart.mmd
|
│ ├── sample-flowchart.mmd
|
||||||
@@ -346,12 +359,13 @@ diagram-design/
|
|||||||
└── docs/screenshots/ — images used in this README
|
└── docs/screenshots/ — images used in this README
|
||||||
```
|
```
|
||||||
|
|
||||||
This keeps the agent's working context tight (only load what you need) and makes the skill easy to extend — drop a new `type-<name>.md` and wire it into the selection guide. The skill ships with 37 reference files covering every diagram type, primitive, and utility.
|
This keeps the agent's working context tight: routine diagrams load one type reference; behavior-rich diagrams add the routed semantic reference; animation adds its contract only when selected.
|
||||||
|
|
||||||
### 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` 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`.
|
||||||
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.
|
||||||
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 —
|
||||||
@@ -365,19 +379,21 @@ All pull requests and pushes are automatically validated across Linux, Windows,
|
|||||||
|
|
||||||
### What loads when
|
### What loads when
|
||||||
|
|
||||||
At startup, the agent sees only the skill name and description. When a request matches, it loads `SKILL.md`; type references are pulled in only when relevant. This keeps the skill fast even with 37 reference files.
|
At startup, the agent sees only the skill name and description. When a request matches, it loads `SKILL.md`; semantic, type, and animation references are pulled in only when relevant.
|
||||||
|
|
||||||
| You ask for… | Agent loads |
|
| You ask for… | Agent loads |
|
||||||
|---|---|
|
|---|---|
|
||||||
| "Make me a flowchart" | `SKILL.md` + `references/type-flowchart.md` |
|
| "Make me a flowchart" | `SKILL.md` + `references/type-flowchart.md` |
|
||||||
| "Build an architecture diagram" | `SKILL.md` + `references/type-architecture.md` |
|
| "Build an architecture diagram" | `SKILL.md` + `references/type-architecture.md` |
|
||||||
|
| "Compare why these two policy requests differ" | `SKILL.md` + `references/semantic-patterns.md` + `references/type-flowchart.md` |
|
||||||
|
| "Animate that policy trace" | Prior selection + `references/animation.md` |
|
||||||
| "Onboard this skill to my site" | `SKILL.md` + `references/onboarding.md` + `references/style-guide.md` |
|
| "Onboard this skill to my site" | `SKILL.md` + `references/onboarding.md` + `references/style-guide.md` |
|
||||||
| "Add an editorial callout to this diagram" | `SKILL.md` + `references/primitive-annotation.md` |
|
| "Add an editorial callout to this diagram" | `SKILL.md` + `references/primitive-annotation.md` |
|
||||||
| "Give me a hand-drawn version" | `SKILL.md` + `references/primitive-sketchy.md` |
|
| "Give me a hand-drawn version" | `SKILL.md` + `references/primitive-sketchy.md` |
|
||||||
| "Give me a terminal / CLI-window version" | `SKILL.md` + `references/primitive-terminal.md` |
|
| "Give me a terminal / CLI-window version" | `SKILL.md` + `references/primitive-terminal.md` |
|
||||||
| "Redraw this .drawio file for my deck" | `SKILL.md` + `references/import-drawio.md` + `references/output-spec.md` + the chosen type's reference |
|
| "Redraw this .drawio file for my deck" | `SKILL.md` + `references/import-drawio.md` + `references/output-spec.md` + the chosen type's reference |
|
||||||
| "Redraw this Mermaid block for my deck" | `SKILL.md` + `references/import-mermaid.md` + `references/output-spec.md` + the chosen type's reference |
|
| "Redraw this Mermaid block for my deck" | `SKILL.md` + `references/import-mermaid.md` + `references/output-spec.md` + the chosen type's reference |
|
||||||
| Routine diagram-making (any of the 27 diagrams) | Only `SKILL.md` + that one type's reference |
|
| Routine static diagram-making (any of the 27 visual types) | Only `SKILL.md` + that one type's reference |
|
||||||
|
|
||||||
No matter how many types exist, the agent only reads the one you need. Add a new type tomorrow and nothing else changes.
|
No matter how many types exist, the agent only reads the one you need. Add a new type tomorrow and nothing else changes.
|
||||||
|
|
||||||
|
|||||||
+223
-20
@@ -6,17 +6,20 @@ skin. Use ``--all --baseline`` to skip those documented legacy files.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
STYLE_GUIDE = ROOT / "skills/diagram-design/references/style-guide.md"
|
STYLE_GUIDE = ROOT / "skills/diagram-design/references/style-guide.md"
|
||||||
ASSET_DIR = ROOT / "skills/diagram-design/assets"
|
ASSET_DIR = ROOT / "skills/diagram-design/assets"
|
||||||
BASELINE = ROOT / "scripts/lint-skin-baseline.txt"
|
BASELINE = ROOT / "scripts/lint-skin-baseline.txt"
|
||||||
|
MOTION_TEMPLATE = ASSET_DIR / "template-motion.html"
|
||||||
|
|
||||||
HEX_RE = re.compile(
|
HEX_RE = re.compile(
|
||||||
r"(?<![\w-])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9a-fA-F])"
|
r"(?<![\w-])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9a-fA-F])"
|
||||||
@@ -26,14 +29,21 @@ RGBA_RE = re.compile(
|
|||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
BLACK_RGB_RE = re.compile(r"rgb\(\s*0\s*,\s*0\s*,\s*0\s*\)", re.IGNORECASE)
|
BLACK_RGB_RE = re.compile(r"rgb\(\s*0\s*,\s*0\s*,\s*0\s*\)", re.IGNORECASE)
|
||||||
SCRIPT_RE = re.compile(r"<script\b", re.IGNORECASE)
|
SCRIPT_OPEN_RE = re.compile(r"<script\b(?P<attrs>[^>]*)>", re.IGNORECASE | re.DOTALL)
|
||||||
SRC_HTTP_RE = re.compile(r"\bsrc\s*=\s*(['\"])\s*https?://", re.IGNORECASE)
|
SCRIPT_BLOCK_RE = re.compile(
|
||||||
IMPORT_HTTP_RE = re.compile(
|
r"<script\b(?P<attrs>[^>]*)>(?P<body>.*?)</script\s*>",
|
||||||
r"@import\s+(?:url\(\s*)?(?:['\"]\s*)?https?://", re.IGNORECASE
|
re.IGNORECASE | re.DOTALL,
|
||||||
)
|
)
|
||||||
URL_HTTP_RE = re.compile(r"url\(\s*(?:['\"]\s*)?https?://", re.IGNORECASE)
|
SRC_HTTP_RE = re.compile(r"\bsrc\s*=\s*(['\"])\s*(?:https?:)?//", re.IGNORECASE)
|
||||||
|
IMPORT_HTTP_RE = re.compile(
|
||||||
|
r"@import\s+(?:url\(\s*)?(?:['\"]\s*)?(?:https?:)?//", re.IGNORECASE
|
||||||
|
)
|
||||||
|
URL_HTTP_RE = re.compile(r"url\(\s*(?:['\"]\s*)?(?:https?:)?//", re.IGNORECASE)
|
||||||
|
IMPORT_ANY_RE = re.compile(r"@import\b", re.IGNORECASE)
|
||||||
|
URL_ANY_RE = re.compile(r"url\(\s*([^)]+?)\s*\)", re.IGNORECASE)
|
||||||
LINK_RE = re.compile(r"<link\b[^>]*>", re.IGNORECASE | re.DOTALL)
|
LINK_RE = re.compile(r"<link\b[^>]*>", re.IGNORECASE | re.DOTALL)
|
||||||
HREF_RE = re.compile(r"\bhref\s*=\s*(['\"])(.*?)\1", re.IGNORECASE | re.DOTALL)
|
HREF_RE = re.compile(r"\bhref\s*=\s*(['\"])(.*?)\1", re.IGNORECASE | re.DOTALL)
|
||||||
|
REL_RE = re.compile(r"\brel\s*=\s*(['\"])(.*?)\1", re.IGNORECASE | re.DOTALL)
|
||||||
FONT_CSS_RE = re.compile(r"font-family\s*:\s*([^;}]+)", re.IGNORECASE)
|
FONT_CSS_RE = re.compile(r"font-family\s*:\s*([^;}]+)", re.IGNORECASE)
|
||||||
FONT_ATTR_RE = re.compile(
|
FONT_ATTR_RE = re.compile(
|
||||||
r"\bfont-family\s*=\s*(['\"])(.*?)\1", re.IGNORECASE | re.DOTALL
|
r"\bfont-family\s*=\s*(['\"])(.*?)\1", re.IGNORECASE | re.DOTALL
|
||||||
@@ -52,6 +62,137 @@ ALLOWED_FONTS = {
|
|||||||
CSS_FONT_KEYWORDS = {"inherit", "initial", "revert", "revert-layer", "unset"}
|
CSS_FONT_KEYWORDS = {"inherit", "initial", "revert", "revert-layer", "unset"}
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_controller(body):
|
||||||
|
"""Return a platform-stable representation of an inline controller."""
|
||||||
|
return body.replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_controller_digest():
|
||||||
|
"""Hash the one reviewed controller shipped by the motion template."""
|
||||||
|
source = MOTION_TEMPLATE.read_text(encoding="utf-8")
|
||||||
|
blocks = list(SCRIPT_BLOCK_RE.finditer(source))
|
||||||
|
if len(blocks) != 1:
|
||||||
|
raise RuntimeError("template-motion.html must contain exactly one controller")
|
||||||
|
body = normalized_controller(blocks[0].group("body"))
|
||||||
|
return hashlib.sha256(body.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def google_fonts_families(url):
|
||||||
|
"""Return exact families from an approved Google Fonts css2 stylesheet."""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError:
|
||||||
|
return set()
|
||||||
|
if (
|
||||||
|
parsed.scheme.casefold() != "https"
|
||||||
|
or parsed.hostname is None
|
||||||
|
or parsed.hostname.casefold() != "fonts.googleapis.com"
|
||||||
|
or port not in (None, 443)
|
||||||
|
or parsed.path != "/css2"
|
||||||
|
or parsed.fragment
|
||||||
|
):
|
||||||
|
return set()
|
||||||
|
families = set()
|
||||||
|
for value in parse_qs(parsed.query, keep_blank_values=True).get("family", []):
|
||||||
|
family = value.split(":", 1)[0].strip()
|
||||||
|
if family:
|
||||||
|
families.add(family.casefold())
|
||||||
|
return families
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceParser(HTMLParser):
|
||||||
|
"""Collect remote resource dependencies without executing the document."""
|
||||||
|
|
||||||
|
RESOURCE_ATTRS = {
|
||||||
|
"base": ("href",),
|
||||||
|
"link": ("href",),
|
||||||
|
"script": ("src",),
|
||||||
|
"img": ("src", "srcset"),
|
||||||
|
"iframe": ("src",),
|
||||||
|
"object": ("data",),
|
||||||
|
"embed": ("src",),
|
||||||
|
"image": ("href", "xlink:href"),
|
||||||
|
"use": ("href", "xlink:href"),
|
||||||
|
"video": ("src", "poster"),
|
||||||
|
"audio": ("src",),
|
||||||
|
"source": ("src", "srcset"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self.remote: list[tuple[int, str, str, str]] = []
|
||||||
|
self.executable: list[tuple[int, str, str]] = []
|
||||||
|
self.fonts: set[str] = set()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_remote(value):
|
||||||
|
try:
|
||||||
|
parsed = urlparse(value.strip())
|
||||||
|
except ValueError:
|
||||||
|
return True
|
||||||
|
return bool(parsed.scheme) or bool(parsed.netloc)
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
tag = tag.casefold()
|
||||||
|
data = {name.casefold(): value or "" for name, value in attrs}
|
||||||
|
line, _column = self.getpos()
|
||||||
|
for name, value in data.items():
|
||||||
|
compact = "".join(character for character in value if ord(character) > 32).casefold()
|
||||||
|
if (
|
||||||
|
name.startswith("on")
|
||||||
|
or name == "srcdoc"
|
||||||
|
or tag == "base"
|
||||||
|
or compact.startswith("javascript:")
|
||||||
|
or compact.startswith("data:text/html")
|
||||||
|
):
|
||||||
|
self.executable.append((line, tag, name))
|
||||||
|
for attr in self.RESOURCE_ATTRS.get(tag, ()):
|
||||||
|
value = data.get(attr, "").strip()
|
||||||
|
candidates = (
|
||||||
|
[item.strip().split()[0] for item in value.split(",") if item.strip()]
|
||||||
|
if attr == "srcset"
|
||||||
|
else [value]
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
if not candidate or not self.is_remote(candidate):
|
||||||
|
continue
|
||||||
|
rel = data.get("rel", "").casefold().split()
|
||||||
|
approved_fonts = (
|
||||||
|
google_fonts_families(candidate)
|
||||||
|
if tag == "link" and attr == "href" and "stylesheet" in rel
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
if approved_fonts:
|
||||||
|
self.fonts.update(approved_fonts)
|
||||||
|
else:
|
||||||
|
self.remote.append((line, tag, attr, candidate))
|
||||||
|
|
||||||
|
|
||||||
|
def style_guide_families():
|
||||||
|
"""Read only the Family cells in the style guide's Typography table."""
|
||||||
|
markdown = STYLE_GUIDE.read_text(encoding="utf-8")
|
||||||
|
match = re.search(
|
||||||
|
r"^## Typography\s*$.*?^\| Role \| Family \|.*?\n(?P<rows>.*?)(?=^### |^## |\Z)",
|
||||||
|
markdown,
|
||||||
|
re.MULTILINE | re.DOTALL,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return set()
|
||||||
|
families = set()
|
||||||
|
for line in match.group("rows").splitlines():
|
||||||
|
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
||||||
|
if len(cells) < 2 or not cells[0].startswith("`"):
|
||||||
|
continue
|
||||||
|
family_stack = re.sub(r"\s*\([^)]*\)\s*$", "", cells[1]).strip(" `*")
|
||||||
|
family_stack = re.sub(r"\s+\*[^*]+\*\s*$", "", family_stack).strip()
|
||||||
|
for family in family_stack.split(","):
|
||||||
|
family = family.strip().strip("'\"")
|
||||||
|
if family:
|
||||||
|
families.add(family.casefold())
|
||||||
|
return families
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SvgTextElement:
|
class SvgTextElement:
|
||||||
tag: str
|
tag: str
|
||||||
@@ -346,20 +487,44 @@ def diagram_slug(path):
|
|||||||
return path.stem.removeprefix("example-")
|
return path.stem.removeprefix("example-")
|
||||||
|
|
||||||
|
|
||||||
def named_families(value):
|
def named_families(value, allowed_fonts):
|
||||||
families = []
|
families = []
|
||||||
for raw_family in value.split(","):
|
for raw_family in value.split(","):
|
||||||
family = raw_family.strip().strip("'\"").strip()
|
family = raw_family.strip().strip("'\"").strip()
|
||||||
lowered = family.casefold()
|
lowered = family.casefold()
|
||||||
if not family or lowered in CSS_FONT_KEYWORDS or lowered.startswith("var("):
|
if not family or lowered in CSS_FONT_KEYWORDS or lowered.startswith("var("):
|
||||||
continue
|
continue
|
||||||
if lowered not in ALLOWED_FONTS:
|
if lowered not in allowed_fonts:
|
||||||
families.append(family)
|
families.append(family)
|
||||||
return families
|
return families
|
||||||
|
|
||||||
|
|
||||||
def lint_text(text, colors, rgb_triplets, expected_slug):
|
def lint_text(text, colors, rgb_triplets, expected_slug):
|
||||||
findings = lint_accessible_svgs(text, expected_slug)
|
findings = lint_accessible_svgs(text, expected_slug)
|
||||||
|
allowed_fonts = ALLOWED_FONTS | style_guide_families()
|
||||||
|
|
||||||
|
resources = ResourceParser()
|
||||||
|
resources.feed(text)
|
||||||
|
resources.close()
|
||||||
|
allowed_fonts.update(resources.fonts)
|
||||||
|
for line, tag, attr, _value in resources.remote:
|
||||||
|
findings.append(
|
||||||
|
(
|
||||||
|
line,
|
||||||
|
0,
|
||||||
|
"external-asset",
|
||||||
|
f"external resource in <{tag}> {attr} is not allowed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for line, tag, attr in resources.executable:
|
||||||
|
findings.append(
|
||||||
|
(
|
||||||
|
line,
|
||||||
|
0,
|
||||||
|
"script",
|
||||||
|
f"executable attribute {attr} on <{tag}> is not allowed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def add(offset, category, message):
|
def add(offset, category, message):
|
||||||
findings.append((line_number(text, offset), offset, category, message))
|
findings.append((line_number(text, offset), offset, category, message))
|
||||||
@@ -384,35 +549,73 @@ def lint_text(text, colors, rgb_triplets, expected_slug):
|
|||||||
for match in BLACK_RGB_RE.finditer(text):
|
for match in BLACK_RGB_RE.finditer(text):
|
||||||
add(match.start(), "pure-black", "pure black rgb(0,0,0) is not allowed")
|
add(match.start(), "pure-black", "pure black rgb(0,0,0) is not allowed")
|
||||||
|
|
||||||
for match in SCRIPT_RE.finditer(text):
|
script_blocks = {match.start(): match for match in SCRIPT_BLOCK_RE.finditer(text)}
|
||||||
add(match.start(), "script", "<script> tags are not allowed")
|
script_openings = list(SCRIPT_OPEN_RE.finditer(text))
|
||||||
|
if len(script_openings) > 1:
|
||||||
|
add(
|
||||||
|
script_openings[1].start(),
|
||||||
|
"script",
|
||||||
|
"only one canonical motion controller is allowed",
|
||||||
|
)
|
||||||
|
canonical_digest = None
|
||||||
|
for match in script_openings:
|
||||||
|
attrs = match.group("attrs")
|
||||||
|
block = script_blocks.get(match.start())
|
||||||
|
canonical_attrs = attrs.strip().casefold() == "data-diagram-controls"
|
||||||
|
if not canonical_attrs:
|
||||||
|
add(
|
||||||
|
match.start(),
|
||||||
|
"script",
|
||||||
|
"only the canonical data-diagram-controls attribute is allowed",
|
||||||
|
)
|
||||||
|
elif block is None:
|
||||||
|
add(
|
||||||
|
match.start(),
|
||||||
|
"script",
|
||||||
|
"motion controller must have a closing script tag",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if canonical_digest is None:
|
||||||
|
canonical_digest = canonical_controller_digest()
|
||||||
|
body = normalized_controller(block.group("body"))
|
||||||
|
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
||||||
|
if digest != canonical_digest:
|
||||||
|
add(
|
||||||
|
match.start(),
|
||||||
|
"script",
|
||||||
|
"motion controller must exactly match template-motion.html",
|
||||||
|
)
|
||||||
|
|
||||||
for match in SRC_HTTP_RE.finditer(text):
|
for match in SRC_HTTP_RE.finditer(text):
|
||||||
add(match.start(), "external-asset", "external HTTP(S) src is not allowed")
|
add(match.start(), "external-asset", "external HTTP(S) src is not allowed")
|
||||||
|
|
||||||
import_spans = []
|
for match in IMPORT_ANY_RE.finditer(text):
|
||||||
for match in IMPORT_HTTP_RE.finditer(text):
|
add(match.start(), "external-asset", "CSS @import is not allowed")
|
||||||
import_spans.append(match.span())
|
|
||||||
add(match.start(), "external-asset", "external HTTP(S) @import is not allowed")
|
|
||||||
|
|
||||||
for match in URL_HTTP_RE.finditer(text):
|
for match in URL_ANY_RE.finditer(text):
|
||||||
if any(start <= match.start() < end for start, end in import_spans):
|
value = match.group(1).strip().strip("'\"").strip()
|
||||||
|
if value.startswith("#"):
|
||||||
continue
|
continue
|
||||||
add(match.start(), "external-asset", "external HTTP(S) url() is not allowed")
|
add(match.start(), "external-asset", "non-fragment CSS url() is not allowed")
|
||||||
|
|
||||||
for link_match in LINK_RE.finditer(text):
|
for link_match in LINK_RE.finditer(text):
|
||||||
href_match = HREF_RE.search(link_match.group())
|
href_match = HREF_RE.search(link_match.group())
|
||||||
if not href_match:
|
if not href_match:
|
||||||
continue
|
continue
|
||||||
href = href_match.group(2).strip()
|
href = href_match.group(2).strip()
|
||||||
if href.lower().startswith("https://fonts.googleapis.com"):
|
approved_families = google_fonts_families(href)
|
||||||
|
rel_match = REL_RE.search(link_match.group())
|
||||||
|
rel_values = rel_match.group(2).casefold().split() if rel_match else []
|
||||||
|
if approved_families and "stylesheet" in rel_values:
|
||||||
|
allowed_fonts.update(approved_families)
|
||||||
continue
|
continue
|
||||||
if href.lower().startswith(("http://", "https://")):
|
parsed_href = urlparse(href)
|
||||||
|
if parsed_href.scheme.casefold() in {"http", "https"} or parsed_href.netloc:
|
||||||
href_offset = link_match.start() + href_match.start(2)
|
href_offset = link_match.start() + href_match.start(2)
|
||||||
add(href_offset, "external-asset", "external HTTP(S) <link> is not allowed")
|
add(href_offset, "external-asset", "external HTTP(S) <link> is not allowed")
|
||||||
|
|
||||||
for match in FONT_CSS_RE.finditer(text):
|
for match in FONT_CSS_RE.finditer(text):
|
||||||
unsupported = named_families(match.group(1))
|
unsupported = named_families(match.group(1), allowed_fonts)
|
||||||
if unsupported:
|
if unsupported:
|
||||||
add(
|
add(
|
||||||
match.start(),
|
match.start(),
|
||||||
@@ -421,7 +624,7 @@ def lint_text(text, colors, rgb_triplets, expected_slug):
|
|||||||
)
|
)
|
||||||
|
|
||||||
for match in FONT_ATTR_RE.finditer(text):
|
for match in FONT_ATTR_RE.finditer(text):
|
||||||
unsupported = named_families(match.group(2))
|
unsupported = named_families(match.group(2), allowed_fonts)
|
||||||
if unsupported:
|
if unsupported:
|
||||||
add(
|
add(
|
||||||
match.start(),
|
match.start(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -14,6 +15,7 @@ from pathlib import Path
|
|||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
LINTER = ROOT / "scripts/lint-skin.py"
|
LINTER = ROOT / "scripts/lint-skin.py"
|
||||||
BUILD_ICONS = ROOT / "scripts/build-icons.py"
|
BUILD_ICONS = ROOT / "scripts/build-icons.py"
|
||||||
|
MOTION_TEMPLATE = ROOT / "skills/diagram-design/assets/template-motion.html"
|
||||||
|
|
||||||
VALID_SVG = """\
|
VALID_SVG = """\
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" role="img"
|
<svg xmlns="http://www.w3.org/2000/svg" role="img"
|
||||||
@@ -62,6 +64,19 @@ def require_pass(name: str, html: str, directory: Path) -> None:
|
|||||||
print(f"OK: {name} accepted")
|
print(f"OK: {name} accepted")
|
||||||
|
|
||||||
|
|
||||||
|
def require_lint_finding(
|
||||||
|
name: str, html: str, expected: str, directory: Path
|
||||||
|
) -> None:
|
||||||
|
path = directory / f"{name}.html"
|
||||||
|
path.write_text(html, encoding="utf-8")
|
||||||
|
result = run_linter(path)
|
||||||
|
if result.returncode != 1 or expected not in result.stdout:
|
||||||
|
raise AssertionError(
|
||||||
|
f"{name}: expected finding {expected!r}\n{result.stdout}{result.stderr}"
|
||||||
|
)
|
||||||
|
print(f"OK: {name} rejected — {expected}")
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
with tempfile.TemporaryDirectory(prefix="lint-a11y-") as temp_dir:
|
with tempfile.TemporaryDirectory(prefix="lint-a11y-") as temp_dir:
|
||||||
directory = Path(temp_dir)
|
directory = Path(temp_dir)
|
||||||
@@ -147,6 +162,207 @@ def main() -> int:
|
|||||||
'<path d="M0 0h1v1z"/></svg>\n',
|
'<path d="M0 0h1v1z"/></svg>\n',
|
||||||
directory,
|
directory,
|
||||||
)
|
)
|
||||||
|
template_source = MOTION_TEMPLATE.read_text(encoding="utf-8")
|
||||||
|
controller_match = re.search(
|
||||||
|
r"<script\b[^>]*data-diagram-controls[^>]*>(.*?)</script\s*>",
|
||||||
|
template_source,
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
if controller_match is None:
|
||||||
|
raise AssertionError("canonical motion controller could not be extracted")
|
||||||
|
controller_body = controller_match.group(1)
|
||||||
|
canonical_script = f"<script data-diagram-controls>{controller_body}</script>\n"
|
||||||
|
motion_svg = VALID_SVG.replace("fixture-title", "motion-controls-title").replace(
|
||||||
|
"fixture-desc", "motion-controls-desc"
|
||||||
|
)
|
||||||
|
require_pass(
|
||||||
|
"motion-controls",
|
||||||
|
motion_svg + canonical_script,
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
duplicate_svg = VALID_SVG.replace(
|
||||||
|
"fixture-title", "duplicate-controller-title"
|
||||||
|
).replace("fixture-desc", "duplicate-controller-desc")
|
||||||
|
require_lint_finding(
|
||||||
|
"duplicate-controller",
|
||||||
|
duplicate_svg + canonical_script + canonical_script,
|
||||||
|
"script: only one canonical motion controller is allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
unscoped_svg = VALID_SVG.replace("fixture-title", "unscoped-script-title").replace(
|
||||||
|
"fixture-desc", "unscoped-script-desc"
|
||||||
|
)
|
||||||
|
require_lint_finding(
|
||||||
|
"unscoped-script",
|
||||||
|
unscoped_svg + "<script>void 0;</script>\n",
|
||||||
|
"script: only the canonical data-diagram-controls attribute is allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
controller_attacks = {
|
||||||
|
"network-controller": "fetch('/collect');",
|
||||||
|
"dynamic-html-controller": "document.body.innerHTML = '<p>changed</p>';",
|
||||||
|
"import-controller": "import('./remote.js');",
|
||||||
|
"eval-controller": "eval('1 + 1');",
|
||||||
|
"navigation-controller": "location.assign('https://example.invalid');",
|
||||||
|
"modified-controller": "void 0;",
|
||||||
|
}
|
||||||
|
for name, injected in controller_attacks.items():
|
||||||
|
attack_svg = VALID_SVG.replace(
|
||||||
|
"fixture-title", f"{name}-title"
|
||||||
|
).replace("fixture-desc", f"{name}-desc")
|
||||||
|
modified = controller_body.replace("(() => {", f"(() => {{\n {injected}", 1)
|
||||||
|
require_lint_finding(
|
||||||
|
name,
|
||||||
|
attack_svg + f"<script data-diagram-controls>{modified}</script>\n",
|
||||||
|
"script: motion controller must exactly match template-motion.html",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
unclosed_svg = VALID_SVG.replace("fixture-title", "unclosed-script-title").replace(
|
||||||
|
"fixture-desc", "unclosed-script-desc"
|
||||||
|
)
|
||||||
|
require_lint_finding(
|
||||||
|
"unclosed-motion-script",
|
||||||
|
unclosed_svg + f"<script data-diagram-controls>{controller_body}\n",
|
||||||
|
"script: motion controller must have a closing script tag",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
remote_svg = VALID_SVG.replace("fixture-title", "remote-script-title").replace(
|
||||||
|
"fixture-desc", "remote-script-desc"
|
||||||
|
)
|
||||||
|
require_lint_finding(
|
||||||
|
"remote-script",
|
||||||
|
remote_svg
|
||||||
|
+ f'<script data-diagram-controls src="https://example.invalid/c.js">{controller_body}</script>\n',
|
||||||
|
"script: only the canonical data-diagram-controls attribute is allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
type_svg = VALID_SVG.replace("fixture-title", "script-type-title").replace(
|
||||||
|
"fixture-desc", "script-type-desc"
|
||||||
|
)
|
||||||
|
require_lint_finding(
|
||||||
|
"script-type-override",
|
||||||
|
type_svg
|
||||||
|
+ f'<script data-diagram-controls type="application/json">{controller_body}</script>\n',
|
||||||
|
"script: only the canonical data-diagram-controls attribute is allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
|
||||||
|
inter_svg = VALID_SVG.replace("fixture-title", "inter-tight-title").replace(
|
||||||
|
"fixture-desc", "inter-tight-desc"
|
||||||
|
)
|
||||||
|
require_pass(
|
||||||
|
"inter-tight",
|
||||||
|
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;600&display=swap">\n'
|
||||||
|
'<style>text { font-family: "Inter Tight", sans-serif; }</style>\n'
|
||||||
|
+ inter_svg,
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
lookalike_svg = VALID_SVG.replace(
|
||||||
|
"fixture-title", "google-fonts-lookalike-title"
|
||||||
|
).replace("fixture-desc", "google-fonts-lookalike-desc")
|
||||||
|
require_lint_finding(
|
||||||
|
"google-fonts-lookalike",
|
||||||
|
'<link rel="stylesheet" href="https://fonts.googleapis.com.evil.test/css2?family=Inter+Tight">\n'
|
||||||
|
'<style>text { font-family: "Inter Tight", sans-serif; }</style>\n'
|
||||||
|
+ lookalike_svg,
|
||||||
|
"external-asset: external HTTP(S) <link> is not allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
bad_path_svg = VALID_SVG.replace(
|
||||||
|
"fixture-title", "google-fonts-bad-path-title"
|
||||||
|
).replace("fixture-desc", "google-fonts-bad-path-desc")
|
||||||
|
require_lint_finding(
|
||||||
|
"google-fonts-bad-path",
|
||||||
|
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2.evil?family=Inter+Tight">\n'
|
||||||
|
+ bad_path_svg,
|
||||||
|
"external-asset: external HTTP(S) <link> is not allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
protocol_relative_svg = VALID_SVG.replace(
|
||||||
|
"fixture-title", "protocol-relative-font-title"
|
||||||
|
).replace("fixture-desc", "protocol-relative-font-desc")
|
||||||
|
require_lint_finding(
|
||||||
|
"protocol-relative-font",
|
||||||
|
'<link rel="stylesheet" href="//fonts.googleapis.com/css2?family=Inter+Tight">\n'
|
||||||
|
+ protocol_relative_svg,
|
||||||
|
"external-asset: external HTTP(S) <link> is not allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
unquoted_remote_svg = VALID_SVG.replace(
|
||||||
|
"fixture-title", "unquoted-remote-title"
|
||||||
|
).replace("fixture-desc", "unquoted-remote-desc")
|
||||||
|
require_lint_finding(
|
||||||
|
"unquoted-remote-link",
|
||||||
|
'<link rel=stylesheet href=https://evil.example/x.css>\n'
|
||||||
|
+ unquoted_remote_svg,
|
||||||
|
"external-asset: external resource in <link> href is not allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
bypass_svg = VALID_SVG.replace("fixture-title", "resource-bypass-title").replace(
|
||||||
|
"fixture-desc", "resource-bypass-desc"
|
||||||
|
)
|
||||||
|
require_lint_finding(
|
||||||
|
"srcset-second-candidate",
|
||||||
|
'<img srcset="local.png 1x, https://evil.example/remote.png 2x">\n'
|
||||||
|
+ bypass_svg,
|
||||||
|
"external-asset: external resource in <img> srcset is not allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
require_lint_finding(
|
||||||
|
"protocol-relative-css-url",
|
||||||
|
'<style>.x { background: url(//evil.example/x.png); }</style>\n'
|
||||||
|
+ bypass_svg,
|
||||||
|
"external-asset: non-fragment CSS url() is not allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
require_lint_finding(
|
||||||
|
"event-handler",
|
||||||
|
'<div onclick="alert(1)"></div>\n' + bypass_svg,
|
||||||
|
"script: executable attribute onclick on <div> is not allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
require_lint_finding(
|
||||||
|
"javascript-url",
|
||||||
|
'<iframe src="javascript:alert(1)"></iframe>\n' + bypass_svg,
|
||||||
|
"script: executable attribute src on <iframe> is not allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
require_lint_finding(
|
||||||
|
"javascript-control-character",
|
||||||
|
'<iframe src="jav	ascript:alert(1)"></iframe>\n' + bypass_svg,
|
||||||
|
"script: executable attribute src on <iframe> is not allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
require_lint_finding(
|
||||||
|
"base-href",
|
||||||
|
'<base href="https://evil.example/">\n' + bypass_svg,
|
||||||
|
"script: executable attribute href on <base> is not allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
require_lint_finding(
|
||||||
|
"iframe-srcdoc",
|
||||||
|
'<iframe srcdoc="<script>alert(1)</script>"></iframe>\n'
|
||||||
|
+ bypass_svg,
|
||||||
|
"script: executable attribute srcdoc on <iframe> is not allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
require_lint_finding(
|
||||||
|
"css-escaped-scheme",
|
||||||
|
'<style>.x { background: url(https\\3a //evil.example/x); }</style>\n'
|
||||||
|
+ bypass_svg,
|
||||||
|
"external-asset: non-fragment CSS url() is not allowed",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
arbitrary_font_svg = VALID_SVG.replace(
|
||||||
|
"fixture-title", "arbitrary-font-title"
|
||||||
|
).replace("fixture-desc", "arbitrary-font-desc")
|
||||||
|
require_lint_finding(
|
||||||
|
"arbitrary-font",
|
||||||
|
'<style>text { font-family: "Unapproved Sans", sans-serif; }</style>\n'
|
||||||
|
+ arbitrary_font_svg,
|
||||||
|
"font-family: unsupported font family: Unapproved Sans",
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
|
||||||
spec = importlib.util.spec_from_file_location("build_icons", BUILD_ICONS)
|
spec = importlib.util.spec_from_file_location("build_icons", BUILD_ICONS)
|
||||||
if spec is None or spec.loader is None:
|
if spec is None or spec.loader is None:
|
||||||
@@ -186,6 +402,34 @@ def main() -> int:
|
|||||||
ROOT / "skills/diagram-design/references/style-guide.md",
|
ROOT / "skills/diagram-design/references/style-guide.md",
|
||||||
project / "skills/diagram-design/references/style-guide.md",
|
project / "skills/diagram-design/references/style-guide.md",
|
||||||
)
|
)
|
||||||
|
project_style = project / "skills/diagram-design/references/style-guide.md"
|
||||||
|
project_style.write_text(
|
||||||
|
project_style.read_text(encoding="utf-8").replace(
|
||||||
|
"| `title` | Instrument Serif |",
|
||||||
|
"| `title` | Inter Tight |",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
brand_font_path = project / "style-guide-font.html"
|
||||||
|
brand_font_path.write_text(
|
||||||
|
VALID_SVG.replace("fixture-title", "style-guide-font-title")
|
||||||
|
.replace("fixture-desc", "style-guide-font-desc")
|
||||||
|
.replace("</svg>", '<text style="font-family: Inter Tight;">Brand</text></svg>'),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
brand_font_result = subprocess.run(
|
||||||
|
[sys.executable, str(project / "scripts/lint-skin.py"), str(brand_font_path)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if brand_font_result.returncode != 0:
|
||||||
|
raise AssertionError(
|
||||||
|
"style-guide font: expected pass\n"
|
||||||
|
f"{brand_font_result.stdout}{brand_font_result.stderr}"
|
||||||
|
)
|
||||||
|
print("OK: exact customized style-guide typography family accepted")
|
||||||
(project / "scripts/lint-skin-baseline.txt").write_text(
|
(project / "scripts/lint-skin-baseline.txt").write_text(
|
||||||
"example-baseline.html\n", encoding="utf-8"
|
"example-baseline.html\n", encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Adversarial tests for verify-motion.py and its canonical template."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
VERIFIER = ROOT / "scripts/verify-motion.py"
|
||||||
|
SEMANTIC_VERIFIER = ROOT / "scripts/verify-semantic-motion.py"
|
||||||
|
TEMPLATE = ROOT / "skills/diagram-design/assets/template-motion.html"
|
||||||
|
EXAMPLE = ROOT / "skills/diagram-design/assets/example-policy-trace-animated.html"
|
||||||
|
|
||||||
|
|
||||||
|
def load_verifier():
|
||||||
|
spec = importlib.util.spec_from_file_location("verify_motion", VERIFIER)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise AssertionError("could not load verify-motion.py")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def load_semantic_verifier():
|
||||||
|
spec = importlib.util.spec_from_file_location("verify_semantic_motion", SEMANTIC_VERIFIER)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise AssertionError("could not load verify-semantic-motion.py")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
module = load_verifier()
|
||||||
|
semantic_module = load_semantic_verifier()
|
||||||
|
source = TEMPLATE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="verify-motion-") as temporary:
|
||||||
|
directory = Path(temporary)
|
||||||
|
|
||||||
|
def check(name: str, html: str, expected: str | None) -> None:
|
||||||
|
path = directory / f"{name}.html"
|
||||||
|
path.write_text(html, encoding="utf-8")
|
||||||
|
errors = module.verify(path)
|
||||||
|
if expected is None:
|
||||||
|
if errors:
|
||||||
|
raise AssertionError(f"{name}: expected pass, got {errors}")
|
||||||
|
print(f"OK: {name} accepted")
|
||||||
|
else:
|
||||||
|
if not any(expected in error for error in errors):
|
||||||
|
raise AssertionError(f"{name}: expected {expected!r}, got {errors}")
|
||||||
|
print(f"OK: {name} rejected — {expected}")
|
||||||
|
|
||||||
|
check("canonical-template", source, None)
|
||||||
|
script_free_none = re.sub(
|
||||||
|
r"<script\b[^>]*>.*?</script\s*>", "", source, flags=re.IGNORECASE | re.DOTALL
|
||||||
|
)
|
||||||
|
script_free_none = re.sub(
|
||||||
|
r"<div data-motion-controls.*?</div>", "", script_free_none, flags=re.DOTALL
|
||||||
|
)
|
||||||
|
script_free_none = re.sub(
|
||||||
|
r"<p class=\"sr-only\" data-motion-status.*?</p>", "", script_free_none, flags=re.DOTALL
|
||||||
|
)
|
||||||
|
script_free_none = re.sub(
|
||||||
|
r"\sdata-motion-item\sdata-step=\"\d+\"", "", script_free_none
|
||||||
|
)
|
||||||
|
script_free_none = script_free_none.replace(
|
||||||
|
'data-motion-mode="step" data-step-count="5"',
|
||||||
|
'data-motion-mode="none" data-step-count="0"',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
check("script-free-none", script_free_none, None)
|
||||||
|
none_with_controls = script_free_none.replace(
|
||||||
|
"</main>", '<div data-motion-controls></div></main>', 1
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"none-with-controls",
|
||||||
|
none_with_controls,
|
||||||
|
"none mode must not expose playback controls or live status",
|
||||||
|
)
|
||||||
|
loop_two = script_free_none.replace(
|
||||||
|
'data-motion-mode="none" data-step-count="0"',
|
||||||
|
'data-motion-mode="loop" data-step-count="2"',
|
||||||
|
1,
|
||||||
|
).replace(
|
||||||
|
"</main>",
|
||||||
|
'<span data-motion-item data-step="1" aria-label="one"></span>'
|
||||||
|
'<span data-motion-item data-step="2" aria-label="two"></span></main>',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"loop-two-semantic-items",
|
||||||
|
loop_two,
|
||||||
|
"loop mode allows at most one semantic item",
|
||||||
|
)
|
||||||
|
example_errors = module.verify(EXAMPLE)
|
||||||
|
if example_errors:
|
||||||
|
raise AssertionError(f"shipped example: expected pass, got {example_errors}")
|
||||||
|
print("OK: shipped animated example accepted")
|
||||||
|
|
||||||
|
shipped = module.shipped_motion_files()
|
||||||
|
if shipped != sorted([TEMPLATE, EXAMPLE], key=lambda path: path.as_posix()):
|
||||||
|
raise AssertionError(f"unexpected shipped motion inventory: {shipped}")
|
||||||
|
print("OK: shipped motion inventory is parsed and complete")
|
||||||
|
check(
|
||||||
|
"bad-mode",
|
||||||
|
source.replace('data-motion-mode="step"', 'data-motion-mode="cinematic"', 1),
|
||||||
|
"data-motion-mode must be one of",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"too-many-steps",
|
||||||
|
source.replace('data-step-count="5"', 'data-step-count="9"', 1),
|
||||||
|
"semantic step count must be 1..8",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"step-gap",
|
||||||
|
source.replace('data-step="4" aria-label="Step 4', 'data-step="3" aria-label="Step 4', 1),
|
||||||
|
"semantic steps must be contiguous",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"missing-pause",
|
||||||
|
source.replace('data-motion-action="pause"', 'data-motion-action="stop"', 1),
|
||||||
|
"missing actions: pause",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"bad-live-region",
|
||||||
|
source.replace('aria-live="polite"', 'aria-live="assertive"', 1),
|
||||||
|
"motion status needs role=status",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"hidden-fallback",
|
||||||
|
source.replace('data-motion-item data-step="1"', 'data-motion-item data-step="1" style="opacity:0"', 1),
|
||||||
|
"fallback must be visible",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"decorative-focus",
|
||||||
|
source.replace('data-motion-decorative aria-hidden="true" focusable="false"', 'data-motion-decorative aria-hidden="false" focusable="true"', 1),
|
||||||
|
"needs aria-hidden=true and focusable=false",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"missing-reduced-motion",
|
||||||
|
source.replace("prefers-reduced-motion", "user-reduced-motion"),
|
||||||
|
"missing reduced-motion CSS fallback",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"reduced-motion-controls-visible",
|
||||||
|
source.replace(
|
||||||
|
"[data-motion-controls] { display: none !important; }",
|
||||||
|
"[data-motion-controls] { display: flex !important; }",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"missing reduced-motion hidden controls",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"hidden-controls-overridden",
|
||||||
|
source.replace("[data-motion-controls][hidden] { display: none !important; }", "", 1),
|
||||||
|
"missing hidden control override",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"missing-static-override",
|
||||||
|
source.replace('data-motion="static"', 'data-motion="still"'),
|
||||||
|
"missing deterministic static override",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"late-title",
|
||||||
|
source.replace(
|
||||||
|
'<title id="template-motion-title">',
|
||||||
|
'<g></g><title id="template-motion-title">',
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"title must be its first child",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"color-only-stage",
|
||||||
|
source.replace('aria-label="Step 1: Request received"', 'aria-description="accent stage"', 1),
|
||||||
|
"needs a non-color aria-label",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"null-test-frame",
|
||||||
|
source.replace("requestedStep !== null", "true", 1),
|
||||||
|
"missing non-null test frame validation",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"fractional-test-frame",
|
||||||
|
source.replace("Number.isSafeInteger(parsedStep)", "Number.isFinite(parsedStep)", 1),
|
||||||
|
"missing integer test frame validation",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"negative-test-frame",
|
||||||
|
source.replace("/^\\d+$/.test(requestedStep)", "/^-?\\d+$/.test(requestedStep)", 1),
|
||||||
|
"missing non-negative decimal test frame validation",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"over-budget-test-frame",
|
||||||
|
source.replace(" && parsedStep <= count", "", 1),
|
||||||
|
"missing bounded test frame validation",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"modified-controller",
|
||||||
|
source.replace(
|
||||||
|
"const params = new URLSearchParams(location.search);",
|
||||||
|
"const params = new URLSearchParams(location.search); void 0;",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"must exactly match the controller in template-motion.html",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"remote-controller",
|
||||||
|
source.replace(
|
||||||
|
"<script data-diagram-controls>",
|
||||||
|
'<script data-diagram-controls src="https://example.invalid/controller.js">',
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"must carry only the canonical data-diagram-controls attribute",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"duplicate-controller-attribute",
|
||||||
|
source.replace(
|
||||||
|
"<script data-diagram-controls>",
|
||||||
|
"<script data-diagram-controls data-diagram-controls>",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"must carry only the canonical data-diagram-controls attribute",
|
||||||
|
)
|
||||||
|
controls_outside = source.replace(
|
||||||
|
" <div data-motion-controls",
|
||||||
|
" </main>\n <div data-motion-controls",
|
||||||
|
1,
|
||||||
|
).replace(" </main>\n\n <script data-diagram-controls>", "\n <script data-diagram-controls>", 1)
|
||||||
|
check(
|
||||||
|
"controls-outside-root",
|
||||||
|
controls_outside,
|
||||||
|
"controlled mode needs one in-root control group; found 0",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"unclosed-controller",
|
||||||
|
source.replace("</script>", "", 1),
|
||||||
|
"must have a closing script tag",
|
||||||
|
)
|
||||||
|
|
||||||
|
broken_trace = directory / "broken-trace.html"
|
||||||
|
broken_trace.write_text(
|
||||||
|
EXAMPLE.read_text(encoding="utf-8").replace(
|
||||||
|
'data-trace-connector="trace-b" x1="936" y1="104" x2="936" y2="336"',
|
||||||
|
'data-trace-connector="trace-b" x1="936" y1="104" x2="936" y2="576"',
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
trace_errors = semantic_module.verify_example(broken_trace)
|
||||||
|
if not any("must terminate at the first FAIL" in error for error in trace_errors):
|
||||||
|
raise AssertionError(f"broken Trace B connector was accepted: {trace_errors}")
|
||||||
|
print("OK: Trace B connector cannot continue through terminal rows")
|
||||||
|
|
||||||
|
cli_result = subprocess.run(
|
||||||
|
[sys.executable, str(VERIFIER), str(TEMPLATE)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if cli_result.returncode != 0 or f"OK {TEMPLATE}" not in cli_result.stdout:
|
||||||
|
raise AssertionError(f"CLI smoke test failed\n{cli_result.stdout}{cli_result.stderr}")
|
||||||
|
print("OK: CLI accepts canonical template")
|
||||||
|
|
||||||
|
print("All motion contract tests passed.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify the optional diagram-motion contract with no third-party deps."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ASSET_DIR = ROOT / "skills/diagram-design/assets"
|
||||||
|
MOTION_TEMPLATE = ASSET_DIR / "template-motion.html"
|
||||||
|
MODES = {"none", "reveal", "step", "loop"}
|
||||||
|
ACTIONS = {"play", "pause", "replay", "prev", "next"}
|
||||||
|
|
||||||
|
|
||||||
|
class MotionParser(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.scripts: list[dict[str, object]] = []
|
||||||
|
self.svgs: list[dict[str, object]] = []
|
||||||
|
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._element_stack: list[str] = []
|
||||||
|
self._motion_root_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 "data-motion-root" in data:
|
||||||
|
self.roots.append(data)
|
||||||
|
if self._motion_root_depth is None:
|
||||||
|
self._motion_root_depth = len(self._element_stack)
|
||||||
|
in_motion_root = self._motion_root_depth is not None
|
||||||
|
if in_motion_root:
|
||||||
|
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-status" in data:
|
||||||
|
self.statuses.append(data)
|
||||||
|
if "data-motion-controls" in data:
|
||||||
|
self.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)
|
||||||
|
self._element_stack.append(tag)
|
||||||
|
if tag == "svg":
|
||||||
|
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 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 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
|
||||||
|
|
||||||
|
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._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) -> MotionParser:
|
||||||
|
parser = MotionParser()
|
||||||
|
parser.feed(source)
|
||||||
|
parser.close()
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_controller() -> str:
|
||||||
|
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 shipped_motion_files() -> list[Path]:
|
||||||
|
"""Enumerate named and parsed motion HTML assets deterministically."""
|
||||||
|
named = set(ASSET_DIR.glob("template-motion*.html"))
|
||||||
|
named.update(ASSET_DIR.glob("example-*-animated*.html"))
|
||||||
|
parsed = set()
|
||||||
|
for path in ASSET_DIR.glob("*.html"):
|
||||||
|
try:
|
||||||
|
document = parsed_document(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
continue
|
||||||
|
has_motion_controller = any(
|
||||||
|
isinstance(script["attrs"], dict)
|
||||||
|
and "data-diagram-controls" in script["attrs"]
|
||||||
|
for script in document.scripts
|
||||||
|
)
|
||||||
|
if document.roots or has_motion_controller:
|
||||||
|
parsed.add(path)
|
||||||
|
return sorted(named | parsed, key=lambda path: path.as_posix())
|
||||||
|
|
||||||
|
|
||||||
|
def verify(path: Path) -> list[str]:
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
parser = parsed_document(source)
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
if len(parser.roots) != 1:
|
||||||
|
errors.append(f"expected exactly one data-motion-root; found {len(parser.roots)}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
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}")
|
||||||
|
try:
|
||||||
|
count = int(root.get("data-step-count", ""))
|
||||||
|
except ValueError:
|
||||||
|
count = -1
|
||||||
|
errors.append("data-step-count must be an integer")
|
||||||
|
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)}")
|
||||||
|
steps: list[int] = []
|
||||||
|
semantic_steps: list[int] = []
|
||||||
|
for index, item in enumerate(parser.items, 1):
|
||||||
|
try:
|
||||||
|
step = int(item.get("data-step", ""))
|
||||||
|
except ValueError:
|
||||||
|
errors.append(f"motion item {index} has a non-integer data-step")
|
||||||
|
continue
|
||||||
|
steps.append(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")
|
||||||
|
else:
|
||||||
|
if 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; 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: number for step, number in Counter(semantic_steps).items() if number > 2}
|
||||||
|
if crowded:
|
||||||
|
errors.append(f"no more than two semantic items may share a step; found {crowded}")
|
||||||
|
if steps and (min(steps) < 1 or (count > 0 and max(steps) > count)):
|
||||||
|
errors.append("motion item data-step falls outside declared step count")
|
||||||
|
|
||||||
|
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 not parser.scripts:
|
||||||
|
errors.append("controlled mode needs the scoped control script")
|
||||||
|
|
||||||
|
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")
|
||||||
|
if mode == "loop":
|
||||||
|
semantic_count = sum(
|
||||||
|
1 for item in parser.items if "data-motion-decorative" not in item
|
||||||
|
)
|
||||||
|
decorative_count = len(parser.items) - semantic_count
|
||||||
|
if semantic_count > 1 or decorative_count > 1:
|
||||||
|
errors.append(
|
||||||
|
"loop mode allows at most one semantic item and one decorative token"
|
||||||
|
)
|
||||||
|
if len(parser.scripts) > 1:
|
||||||
|
errors.append(f"motion document may contain at most one script; 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"
|
||||||
|
)
|
||||||
|
if normalized_controller("".join(body)) != canonical_controller():
|
||||||
|
errors.append(
|
||||||
|
f"script {number} must exactly match the controller in template-motion.html"
|
||||||
|
)
|
||||||
|
|
||||||
|
required_source = {
|
||||||
|
"prefers-reduced-motion": "reduced-motion CSS fallback",
|
||||||
|
"@media print": "print CSS fallback",
|
||||||
|
}
|
||||||
|
if parser.scripts:
|
||||||
|
required_source.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",
|
||||||
|
"document.fonts": "font-stable screenshot hook",
|
||||||
|
"visibilitychange": "background-tab pause",
|
||||||
|
"ArrowRight": "keyboard stepping",
|
||||||
|
"motion-ready": "progressive enhancement class",
|
||||||
|
"requestedStep !== null": "non-null test frame validation",
|
||||||
|
"/^\\d+$/.test(requestedStep)": "non-negative decimal test frame validation",
|
||||||
|
"Number.isSafeInteger(parsedStep)": "integer test frame validation",
|
||||||
|
"parsedStep <= count": "bounded test frame validation",
|
||||||
|
"if (step >= count) { finish(); return; }": "immediate final-step stop",
|
||||||
|
"playback controls unavailable": "static/reduced-motion status",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for needle, label in required_source.items():
|
||||||
|
if needle not in source:
|
||||||
|
errors.append(f"missing {label} ({needle})")
|
||||||
|
|
||||||
|
if re.search(r"(?<!motion-ready)\s\[data-motion-item\]\s*\{[^}]*opacity\s*:\s*0", source, re.S):
|
||||||
|
errors.append("unscoped data-motion-item opacity:0 breaks the no-JS fallback")
|
||||||
|
if mode != "loop" and re.search(r"animation\s*:[^;}]*\binfinite\b", source):
|
||||||
|
errors.append("only loop mode may declare an infinite animation shorthand")
|
||||||
|
if mode == "loop" and len(parser.items) > 2:
|
||||||
|
errors.append("loop mode may contain at most one semantic item and one decorative token")
|
||||||
|
|
||||||
|
if not parser.svgs:
|
||||||
|
errors.append("motion document needs an accessible SVG")
|
||||||
|
for number, svg in enumerate(parser.svgs, 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", {})
|
||||||
|
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")
|
||||||
|
if not isinstance(title_attrs, dict) or not isinstance(desc_attrs, dict) or labelled != [title_attrs.get("id"), desc_attrs.get("id")]:
|
||||||
|
errors.append(f"svg {number} aria-labelledby must name title then desc")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
argument_parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
argument_parser.add_argument("files", nargs="*", type=Path)
|
||||||
|
argument_parser.add_argument(
|
||||||
|
"--shipped",
|
||||||
|
action="store_true",
|
||||||
|
help="verify every named or parsed motion HTML asset shipped by the repository",
|
||||||
|
)
|
||||||
|
args = argument_parser.parse_args()
|
||||||
|
files = list(args.files)
|
||||||
|
if args.shipped:
|
||||||
|
files.extend(shipped_motion_files())
|
||||||
|
files = list(dict.fromkeys(files))
|
||||||
|
if not files:
|
||||||
|
argument_parser.error("provide files or use --shipped")
|
||||||
|
failed = False
|
||||||
|
for path in 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())
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify semantic-pattern routing and the animated example contract.
|
||||||
|
|
||||||
|
Uses only the Python standard library and never executes example JavaScript.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 parse_qs, urlparse
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SKILL = ROOT / "skills/diagram-design/SKILL.md"
|
||||||
|
PATTERNS = ROOT / "skills/diagram-design/references/semantic-patterns.md"
|
||||||
|
ANIMATION = ROOT / "skills/diagram-design/references/animation.md"
|
||||||
|
EXAMPLE = ROOT / "skills/diagram-design/assets/example-policy-trace-animated.html"
|
||||||
|
MAX_SKILL_BYTES = 35_000
|
||||||
|
|
||||||
|
PATTERN_NAMES = (
|
||||||
|
"Fan-in queue / bottleneck",
|
||||||
|
"Stage framework with semantic slots",
|
||||||
|
"Unstructured input → structured artifact",
|
||||||
|
"Paired policy-evaluation traces",
|
||||||
|
"Secure paved road",
|
||||||
|
"Governance / control catalog",
|
||||||
|
"Compensating security layers",
|
||||||
|
)
|
||||||
|
PATTERN_FIELDS = (
|
||||||
|
"Selection triggers:",
|
||||||
|
"Required primitives:",
|
||||||
|
"Complexity budget:",
|
||||||
|
"Anti-patterns:",
|
||||||
|
"Static fallback:",
|
||||||
|
"Nearest visual type:",
|
||||||
|
)
|
||||||
|
MOTION_MODES = ("none", "reveal", "step", "loop")
|
||||||
|
MOTION_PRIMITIVES = (
|
||||||
|
"Path draw",
|
||||||
|
"Staggered reveal",
|
||||||
|
"Queue counter",
|
||||||
|
"Typing / field population",
|
||||||
|
"Policy evaluation",
|
||||||
|
"Flow token",
|
||||||
|
"Containment",
|
||||||
|
"Audit append",
|
||||||
|
)
|
||||||
|
CONTROL_ACTIONS = {"play", "pause", "replay", "prev", "next"}
|
||||||
|
|
||||||
|
|
||||||
|
def is_approved_google_fonts_stylesheet(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
parsed = urlparse(value)
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
parsed.scheme.casefold() == "https"
|
||||||
|
and parsed.hostname is not None
|
||||||
|
and parsed.hostname.casefold() == "fonts.googleapis.com"
|
||||||
|
and port in (None, 443)
|
||||||
|
and parsed.path == "/css2"
|
||||||
|
and not parsed.fragment
|
||||||
|
and any(
|
||||||
|
family.strip()
|
||||||
|
for family in parse_qs(parsed.query, keep_blank_values=True).get("family", [])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContractParser(HTMLParser):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self.ids: list[str] = []
|
||||||
|
self.controls: set[str] = set()
|
||||||
|
self.motion_roots: list[dict[str, str]] = []
|
||||||
|
self.motion_items: list[dict[str, str]] = []
|
||||||
|
self.statuses: list[dict[str, str]] = []
|
||||||
|
self.scripts: list[dict[str, str]] = []
|
||||||
|
self.remote_assets: list[tuple[str, str]] = []
|
||||||
|
self.trace_connectors: list[dict[str, str]] = []
|
||||||
|
self.trace_b_terminals: list[dict[str, str]] = []
|
||||||
|
self.trace_b_not_reached: list[dict[str, str]] = []
|
||||||
|
self.trace_b_outcomes: list[dict[str, str]] = []
|
||||||
|
self.svg_lines: list[dict[str, str]] = []
|
||||||
|
self.noscript_count = 0
|
||||||
|
self.svgs: list[dict[str, object]] = []
|
||||||
|
self._svg_depth = 0
|
||||||
|
self._current_svg: dict[str, object] | None = None
|
||||||
|
self._capture: str | None = None
|
||||||
|
|
||||||
|
def handle_starttag(
|
||||||
|
self, tag: str, attrs: list[tuple[str, str | None]]
|
||||||
|
) -> None:
|
||||||
|
data = {name.casefold(): value or "" for name, value in attrs}
|
||||||
|
if data.get("id"):
|
||||||
|
self.ids.append(data["id"])
|
||||||
|
if "data-motion-root" in data:
|
||||||
|
self.motion_roots.append(data)
|
||||||
|
if "data-motion-item" in data:
|
||||||
|
self.motion_items.append(data)
|
||||||
|
if data.get("data-motion-action"):
|
||||||
|
self.controls.add(data["data-motion-action"])
|
||||||
|
if data.get("role") == "status":
|
||||||
|
self.statuses.append(data)
|
||||||
|
if tag == "script":
|
||||||
|
self.scripts.append(data)
|
||||||
|
if tag == "noscript":
|
||||||
|
self.noscript_count += 1
|
||||||
|
if data.get("data-trace-connector") == "trace-b":
|
||||||
|
self.trace_connectors.append(data)
|
||||||
|
if data.get("data-trace-b-terminal") == "fail":
|
||||||
|
self.trace_b_terminals.append(data)
|
||||||
|
if data.get("data-trace-b-state") == "not-reached":
|
||||||
|
self.trace_b_not_reached.append(data)
|
||||||
|
if data.get("data-trace-b-outcome") == "denied":
|
||||||
|
self.trace_b_outcomes.append(data)
|
||||||
|
if tag == "line":
|
||||||
|
self.svg_lines.append(data)
|
||||||
|
|
||||||
|
for attr in ("src", "href"):
|
||||||
|
value = data.get(attr, "").strip()
|
||||||
|
try:
|
||||||
|
parsed_value = urlparse(value)
|
||||||
|
remote = parsed_value.scheme.casefold() in {"http", "https"} or bool(parsed_value.netloc)
|
||||||
|
except ValueError:
|
||||||
|
remote = True
|
||||||
|
if value and remote:
|
||||||
|
allowed_font = tag == "link" and attr == "href" and is_approved_google_fonts_stylesheet(value)
|
||||||
|
if not allowed_font:
|
||||||
|
self.remote_assets.append((tag, value))
|
||||||
|
|
||||||
|
if tag == "svg":
|
||||||
|
self._svg_depth = 1
|
||||||
|
self._current_svg = {
|
||||||
|
"attrs": data,
|
||||||
|
"first_child": None,
|
||||||
|
"title": {"attrs": {}, "text": ""},
|
||||||
|
"desc": {"attrs": {}, "text": ""},
|
||||||
|
}
|
||||||
|
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_child"] is None:
|
||||||
|
self._current_svg["first_child"] = tag
|
||||||
|
if tag in {"title", "desc"} and self._svg_depth == 2:
|
||||||
|
self._current_svg[tag] = {"attrs": data, "text": ""}
|
||||||
|
self._capture = tag
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
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
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
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 section(markdown: str, heading: str, next_heading: str | None) -> str:
|
||||||
|
start = markdown.find(heading)
|
||||||
|
if start < 0:
|
||||||
|
return ""
|
||||||
|
end = markdown.find(next_heading, start + len(heading)) if next_heading else -1
|
||||||
|
return markdown[start:] if end < 0 else markdown[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def verify_markdown() -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
skill_bytes = SKILL.read_bytes()
|
||||||
|
skill = skill_bytes.decode("utf-8")
|
||||||
|
patterns = PATTERNS.read_text(encoding="utf-8")
|
||||||
|
animation = ANIMATION.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
if len(skill_bytes) > MAX_SKILL_BYTES:
|
||||||
|
errors.append(
|
||||||
|
f"SKILL.md exceeds {MAX_SKILL_BYTES} bytes: {len(skill_bytes)} bytes"
|
||||||
|
)
|
||||||
|
if "Selection: semantic pattern, then visual type" not in skill:
|
||||||
|
errors.append("SKILL.md must choose semantic pattern before visual type")
|
||||||
|
if skill.find("semantic-patterns.md") > skill.find("### Visual-type guide (27)"):
|
||||||
|
errors.append("semantic-pattern router must precede the visual-type guide")
|
||||||
|
|
||||||
|
visual_guide = section(skill, "### Visual-type guide (27)", "Rules of thumb:")
|
||||||
|
visual_rows = re.findall(r"^\|.*\[type-[^)]+\.md\]", visual_guide, re.MULTILINE)
|
||||||
|
if len(visual_rows) != 27:
|
||||||
|
errors.append(f"visual-type guide must preserve 27 rows; found {len(visual_rows)}")
|
||||||
|
|
||||||
|
for index, name in enumerate(PATTERN_NAMES, 1):
|
||||||
|
if name not in skill:
|
||||||
|
errors.append(f"SKILL.md does not route semantic pattern: {name}")
|
||||||
|
next_heading = f"## {index + 1}." if index < len(PATTERN_NAMES) else "## Composition rules"
|
||||||
|
block = section(patterns, f"## {index}. {name}", next_heading)
|
||||||
|
if not block:
|
||||||
|
errors.append(f"semantic-patterns.md is missing section {index}: {name}")
|
||||||
|
continue
|
||||||
|
for field in PATTERN_FIELDS:
|
||||||
|
if f"**{field}**" not in block:
|
||||||
|
errors.append(f"{name} is missing required field {field}")
|
||||||
|
|
||||||
|
for mode in MOTION_MODES:
|
||||||
|
if f"`{mode}`" not in animation:
|
||||||
|
errors.append(f"animation.md is missing mode {mode}")
|
||||||
|
for primitive in MOTION_PRIMITIVES:
|
||||||
|
if f"**{primitive}**" not in animation:
|
||||||
|
errors.append(f"animation.md is missing primitive {primitive}")
|
||||||
|
|
||||||
|
required_animation_terms = (
|
||||||
|
"prefers-reduced-motion",
|
||||||
|
"CSS-only",
|
||||||
|
"inline JavaScript",
|
||||||
|
"Play, Pause, Replay, Previous, and Next",
|
||||||
|
"deterministic",
|
||||||
|
"static",
|
||||||
|
"color-only",
|
||||||
|
"screenshot",
|
||||||
|
"export",
|
||||||
|
)
|
||||||
|
for term in required_animation_terms:
|
||||||
|
if term not in animation:
|
||||||
|
errors.append(f"animation.md is missing contract term {term!r}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def verify_example(path: Path = EXAMPLE) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
parser = ContractParser()
|
||||||
|
parser.feed(source)
|
||||||
|
parser.close()
|
||||||
|
|
||||||
|
duplicates = sorted(name for name, count in Counter(parser.ids).items() if count > 1)
|
||||||
|
if duplicates:
|
||||||
|
errors.append("duplicate HTML/SVG IDs: " + ", ".join(duplicates))
|
||||||
|
if parser.remote_assets:
|
||||||
|
errors.append(f"remote dependencies other than allowed fonts: {parser.remote_assets}")
|
||||||
|
if len(parser.scripts) != 1:
|
||||||
|
errors.append(f"expected one minimal inline control script; found {len(parser.scripts)}")
|
||||||
|
else:
|
||||||
|
script = parser.scripts[0]
|
||||||
|
if script.get("src"):
|
||||||
|
errors.append("remote or file-based scripts are forbidden")
|
||||||
|
if "data-diagram-controls" not in script:
|
||||||
|
errors.append("inline script must be scoped with data-diagram-controls")
|
||||||
|
|
||||||
|
if len(parser.motion_roots) != 1:
|
||||||
|
errors.append(f"expected one motion root; found {len(parser.motion_roots)}")
|
||||||
|
else:
|
||||||
|
root = parser.motion_roots[0]
|
||||||
|
if root.get("data-motion-mode") != "step":
|
||||||
|
errors.append("working example must use step mode")
|
||||||
|
if root.get("data-static-frame") != "complete":
|
||||||
|
errors.append("motion root must declare a complete static fallback")
|
||||||
|
if root.get("data-frame") != "static":
|
||||||
|
errors.append("source first frame must be static")
|
||||||
|
|
||||||
|
missing_controls = CONTROL_ACTIONS - parser.controls
|
||||||
|
if missing_controls:
|
||||||
|
errors.append("missing controls: " + ", ".join(sorted(missing_controls)))
|
||||||
|
if not parser.statuses:
|
||||||
|
errors.append("interactive example needs accessible status text")
|
||||||
|
else:
|
||||||
|
status = parser.statuses[0]
|
||||||
|
if status.get("aria-live") != "polite" or status.get("aria-atomic") != "true":
|
||||||
|
errors.append("status needs aria-live=polite and aria-atomic=true")
|
||||||
|
if parser.noscript_count != 1:
|
||||||
|
errors.append("example needs one explicit no-JavaScript fallback")
|
||||||
|
|
||||||
|
if (
|
||||||
|
len(parser.trace_connectors) != 1
|
||||||
|
or len(parser.trace_b_terminals) != 1
|
||||||
|
or len(parser.trace_b_not_reached) != 2
|
||||||
|
or len(parser.trace_b_outcomes) != 1
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
"Trace B needs one persistent connector, one terminal FAIL, "
|
||||||
|
"two NOT REACHED rows, and one denied outcome"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
connector = parser.trace_connectors[0]
|
||||||
|
terminal = parser.trace_b_terminals[0]
|
||||||
|
not_reached = parser.trace_b_not_reached
|
||||||
|
outcome = parser.trace_b_outcomes[0]
|
||||||
|
try:
|
||||||
|
connector_end = float(connector["y2"])
|
||||||
|
fail_end = float(terminal["y"]) + float(terminal["height"])
|
||||||
|
later_starts = [float(node["y"]) for node in not_reached] + [float(outcome["y"])]
|
||||||
|
connector_x = float(connector["x1"])
|
||||||
|
vertical = connector_x == float(connector["x2"])
|
||||||
|
continuations = [
|
||||||
|
line
|
||||||
|
for line in parser.svg_lines
|
||||||
|
if line is not connector
|
||||||
|
and float(line.get("x1", "nan")) == connector_x
|
||||||
|
and float(line.get("x2", "nan")) == connector_x
|
||||||
|
and max(float(line.get("y1", "nan")), float(line.get("y2", "nan"))) > fail_end
|
||||||
|
]
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
errors.append("Trace B structural coordinates must be numeric")
|
||||||
|
else:
|
||||||
|
if (
|
||||||
|
not vertical
|
||||||
|
or connector_end != fail_end
|
||||||
|
or any(connector_end >= start for start in later_starts)
|
||||||
|
or continuations
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
"Trace B persistent connector must terminate at the first FAIL "
|
||||||
|
"before NOT REACHED rows and the denied outcome"
|
||||||
|
)
|
||||||
|
|
||||||
|
steps: list[int] = []
|
||||||
|
for item in parser.motion_items:
|
||||||
|
try:
|
||||||
|
steps.append(int(item.get("data-step", "")))
|
||||||
|
except ValueError:
|
||||||
|
errors.append("every motion item needs an integer data-step")
|
||||||
|
if not item.get("aria-label", "").strip():
|
||||||
|
errors.append("every semantic motion item needs an aria-label")
|
||||||
|
if steps != [1, 2, 3, 4, 5]:
|
||||||
|
errors.append(f"policy steps must be exactly [1, 2, 3, 4, 5]; found {steps}")
|
||||||
|
|
||||||
|
if len(parser.svgs) != 1:
|
||||||
|
errors.append(f"expected one SVG; found {len(parser.svgs)}")
|
||||||
|
else:
|
||||||
|
svg = parser.svgs[0]
|
||||||
|
attrs = svg["attrs"]
|
||||||
|
title = svg["title"]
|
||||||
|
desc = svg["desc"]
|
||||||
|
assert isinstance(attrs, dict) and isinstance(title, dict) and isinstance(desc, dict)
|
||||||
|
title_attrs = title.get("attrs", {})
|
||||||
|
desc_attrs = desc.get("attrs", {})
|
||||||
|
labelled = attrs.get("aria-labelledby", "").split()
|
||||||
|
if attrs.get("role") != "img" or svg["first_child"] != "title":
|
||||||
|
errors.append("SVG needs role=img and title as first child")
|
||||||
|
if not str(title.get("text", "")).strip() or not str(desc.get("text", "")).strip():
|
||||||
|
errors.append("SVG title and description must be non-empty")
|
||||||
|
if not isinstance(title_attrs, dict) or not isinstance(desc_attrs, dict):
|
||||||
|
errors.append("SVG title/description attributes could not be parsed")
|
||||||
|
elif labelled != [title_attrs.get("id"), desc_attrs.get("id")]:
|
||||||
|
errors.append("SVG aria-labelledby must resolve to title then description")
|
||||||
|
|
||||||
|
required_source = {
|
||||||
|
"@media (prefers-reduced-motion: reduce)": "reduced-motion fallback",
|
||||||
|
"@media print": "print/static fallback",
|
||||||
|
'data-static-frame="complete"': "complete source fallback",
|
||||||
|
"?motion=static": "static query documentation",
|
||||||
|
"params.get('motion') === 'static'": "static screenshot path",
|
||||||
|
"document.fonts.ready": "font-stable screenshot hook",
|
||||||
|
"visibilitychange": "background pause",
|
||||||
|
"ArrowRight": "right-arrow keyboard path",
|
||||||
|
"ArrowLeft": "left-arrow keyboard path",
|
||||||
|
"event.code === 'Space'": "space play/pause path",
|
||||||
|
"Home": "home/reset keyboard path",
|
||||||
|
"toLowerCase() === 'r'": "replay keyboard path",
|
||||||
|
"window.setTimeout": "deterministic timer",
|
||||||
|
"requestedStep !== null": "non-null test-step validation",
|
||||||
|
"Number.isSafeInteger(parsedStep)": "integer test-step validation",
|
||||||
|
"parsedStep <= count": "bounded test-step validation",
|
||||||
|
"playback controls unavailable": "static/reduced-motion browser status",
|
||||||
|
"PASS": "pass text state",
|
||||||
|
"FAIL": "fail text state",
|
||||||
|
"SKIPPED": "skipped text state",
|
||||||
|
"NOT REACHED": "not-reached text state",
|
||||||
|
"FIRST DIVERGENCE": "first-divergence marker",
|
||||||
|
}
|
||||||
|
for needle, label in required_source.items():
|
||||||
|
if needle not in source:
|
||||||
|
errors.append(f"missing {label}: {needle}")
|
||||||
|
if "setInterval" in source or "Math.random" in source:
|
||||||
|
errors.append("motion timing must not use setInterval or randomness")
|
||||||
|
if re.search(r"<script\b[^>]*\bsrc\s*=", source, re.IGNORECASE):
|
||||||
|
errors.append("example must not load any remote script")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
argument_parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
group = argument_parser.add_mutually_exclusive_group()
|
||||||
|
group.add_argument("--markdown-only", action="store_true")
|
||||||
|
group.add_argument("--example-only", action="store_true")
|
||||||
|
args = argument_parser.parse_args()
|
||||||
|
errors = (
|
||||||
|
verify_markdown()
|
||||||
|
if args.markdown_only
|
||||||
|
else verify_example()
|
||||||
|
if args.example_only
|
||||||
|
else verify_markdown() + verify_example()
|
||||||
|
)
|
||||||
|
if errors:
|
||||||
|
print("Semantic/motion verification failed:")
|
||||||
|
for error in errors:
|
||||||
|
print(f"- {error}")
|
||||||
|
return 1
|
||||||
|
if not args.example_only:
|
||||||
|
print("OK: 7 semantic patterns route independently to the preserved 27 visual types")
|
||||||
|
print("OK: animation modes, primitives, static fallback, and accessibility contract")
|
||||||
|
if not args.markdown_only:
|
||||||
|
print("OK: policy-trace example controls, structure, reduced motion, and unique IDs")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
---
|
---
|
||||||
name: diagram-design
|
name: diagram-design
|
||||||
description: Create technical and product diagrams — 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 chart, line chart, Gantt, scatter plot, high-level, process, medallion, data flow, DP integration, DP security matrix — as standalone HTML files with inline SVG. Also imports existing draw.io / diagrams.net files (.drawio, .drawio.png, .drawio.svg) and Mermaid source (.mmd, .mermaid, or Markdown fences), redrawing them at a chosen output format (HTML / SVG / PNG), canvas size (slide, social card, doc, print), and level of detail (faithful reproduction or simplified for the audience's technical level). Ships with a neutral editorial skin and a first-run gate that prompts users to customize the style guide (colors, fonts) from their own website before generating. Includes annotation-callout primitive and optional sketchy variant.
|
description: Create branded technical and product diagrams in 27 visual types, with semantic patterns, optional accessible motion, and .drawio/Mermaid import.
|
||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
version: "2.2"
|
version: "2.3"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Diagram Design
|
# Diagram Design
|
||||||
|
|
||||||
Create visual diagrams as self-contained HTML files with inline SVG and CSS, following an opinionated editorial design system.
|
Create visual diagrams as self-contained HTML files with inline SVG and CSS, following an opinionated editorial design system.
|
||||||
|
|
||||||
Twenty-seven diagram types. One shared design system, complexity budget, and taste gate. Type-specific conventions live in `references/` and are loaded only when you pick a type.
|
Twenty-seven visual types. Semantic patterns describe behavior independently; type references describe layout. Details load from `references/` only when selected.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,16 +32,12 @@ Then branch:
|
|||||||
|
|
||||||
**Once the style guide has been customized** (or the user explicitly opted for default), skip this gate on subsequent runs. A simple way to detect customization: if the `accent` value in `style-guide.md` differs from `#b5523a`, assume custom.
|
**Once the style guide has been customized** (or the user explicitly opted for default), skip this gate on subsequent runs. A simple way to detect customization: if the `accent` value in `style-guide.md` differs from `#b5523a`, assume custom.
|
||||||
|
|
||||||
Don't silently ship default-skinned diagrams into a branded project — that's the failure mode this gate exists to prevent.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Philosophy
|
## 1. Philosophy
|
||||||
|
|
||||||
**The highest-quality move is usually deletion.**
|
**The highest-quality move is usually deletion.**
|
||||||
|
|
||||||
From `.impeccable.md`: *"Confident restraint. Earn every element. One color accent, two families, a small spacing vocabulary. If removing it wouldn't hurt the page, remove it."*
|
|
||||||
|
|
||||||
Applied to schematics:
|
Applied to schematics:
|
||||||
|
|
||||||
- Every node represents a distinct idea. Two nodes that always travel together are one node.
|
- Every node represents a distinct idea. Two nodes that always travel together are one node.
|
||||||
@@ -68,9 +64,23 @@ Before drawing, ask: *Would the reader learn more from this than from a well-wri
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Diagram Types
|
## 3. Selection: semantic pattern, then visual type
|
||||||
|
|
||||||
### Selection guide
|
When behavior, state, enforcement, or risk carries the meaning, first load [`references/semantic-patterns.md`](references/semantic-patterns.md) and choose one primary pattern. Then choose the nearest visual type for layout. If no pattern matches, choose the type directly.
|
||||||
|
|
||||||
|
| Behavioral trigger | Semantic pattern → nearest type |
|
||||||
|
|---|---|
|
||||||
|
| Fan-in, queue depth, finite capacity, bottleneck | **Fan-in queue / bottleneck** → Data flow |
|
||||||
|
| Repeated Question / Input / Governance / Output slots across stages | **Stage framework with semantic slots** → Process |
|
||||||
|
| Conversation or loose input becomes a structured durable artifact | **Unstructured input → structured artifact** → Data flow |
|
||||||
|
| Two rule traces need pass/fail/skipped/not-reached and first divergence | **Paired policy-evaluation traces** → Flowchart |
|
||||||
|
| Trust boundaries plus permitted/forbidden ingress or deploy paths | **Secure paved road** → Architecture |
|
||||||
|
| Controls grouped by where they are enforced | **Governance / control catalog** → Layer stack |
|
||||||
|
| Defenses compensate for prior gaps and residual risk propagates | **Compensating security layers** → Layer stack |
|
||||||
|
|
||||||
|
The pattern owns semantic primitives and its tighter budget; the type owns layout grammar. Use [`references/animation.md`](references/animation.md) only when motion is requested or materially clarifies ordered change; static remains the default.
|
||||||
|
|
||||||
|
### Visual-type guide (27)
|
||||||
|
|
||||||
| If you're showing… | Use | Reference |
|
| If you're showing… | Use | Reference |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -105,10 +115,10 @@ Before drawing, ask: *Would the reader learn more from this than from a well-wri
|
|||||||
Rules of thumb:
|
Rules of thumb:
|
||||||
|
|
||||||
- If a 3-column table communicates the same thing, pick the table.
|
- If a 3-column table communicates the same thing, pick the table.
|
||||||
- If you're combining two types, pick the dominant axis — don't hybridize grammars.
|
- If two types seem useful, pick the dominant axis; a semantic pattern may add behavior-specific primitives, not a second layout grammar.
|
||||||
- If you're past the complexity budget (§7), split into an overview + detail.
|
- If you're past the complexity budget (§7), split into an overview + detail.
|
||||||
|
|
||||||
**Always load the relevant `references/type-*.md` before drawing** — it contains layout conventions, anti-patterns, and example files for that type.
|
**Always load the chosen `references/type-*.md` before drawing.** When routed above, also load `semantic-patterns.md`; when animation is chosen, load `animation.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -195,6 +205,7 @@ Universal building blocks. Type-specialized primitives (lifeline, activation bar
|
|||||||
- Hand-drawn variant → [primitive-sketchy.md](references/primitive-sketchy.md)
|
- Hand-drawn variant → [primitive-sketchy.md](references/primitive-sketchy.md)
|
||||||
- Icon set (laptop, server, DB, K8s, Docker, AWS, …) → [primitive-icons.md](references/primitive-icons.md). Browse the gallery at [`assets/icons.html`](assets/icons.html).
|
- Icon set (laptop, server, DB, K8s, Docker, AWS, …) → [primitive-icons.md](references/primitive-icons.md). Browse the gallery at [`assets/icons.html`](assets/icons.html).
|
||||||
- Terminal / CLI-window variant → [primitive-terminal.md](references/primitive-terminal.md)
|
- Terminal / CLI-window variant → [primitive-terminal.md](references/primitive-terminal.md)
|
||||||
|
- Optional explanatory motion → [animation.md](references/animation.md)
|
||||||
|
|
||||||
### Background
|
### Background
|
||||||
|
|
||||||
@@ -366,6 +377,7 @@ Quick check: if a coordinate ends in 1, 2, 3, 5, 6, 7, 9 — fix it.
|
|||||||
| Max tasks (Gantt) | 12 |
|
| Max tasks (Gantt) | 12 |
|
||||||
| Max points (scatter plot) | 30 |
|
| Max points (scatter plot) | 30 |
|
||||||
| Max annotation callouts | 2 |
|
| Max annotation callouts | 2 |
|
||||||
|
| Max motion (optional) | 8 steps, 12 marked items, 2 simultaneous items — see [animation.md](references/animation.md) |
|
||||||
|
|
||||||
If you exceed, split into two diagrams (overview + detail).
|
If you exceed, split into two diagrams (overview + detail).
|
||||||
|
|
||||||
@@ -409,7 +421,8 @@ Run before producing any diagram.
|
|||||||
|
|
||||||
**Type fit:**
|
**Type fit:**
|
||||||
|
|
||||||
- [ ] Right type for what I'm showing? (§3 selection guide)
|
- [ ] 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)
|
||||||
- [ ] Would a table / paragraph do the same job? (If yes — don't draw.)
|
- [ ] Would a table / paragraph do the same job? (If yes — don't draw.)
|
||||||
- [ ] Loaded the matching `references/type-*.md`?
|
- [ ] 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))
|
- [ ] 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))
|
||||||
@@ -444,9 +457,11 @@ 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?
|
||||||
|
|
||||||
**Typography:**
|
**Typography:**
|
||||||
|
|
||||||
|
- [ ] Brand match uses exact public families/weights, verified via `getComputedStyle`; fallbacks disclosed?
|
||||||
- [ ] Human-readable names in Geist sans, not Geist Mono?
|
- [ ] Human-readable names in Geist sans, not Geist Mono?
|
||||||
- [ ] Technical sublabels (ports, commands, URLs) in Geist Mono?
|
- [ ] Technical sublabels (ports, commands, URLs) in Geist Mono?
|
||||||
- [ ] Page title in Instrument Serif?
|
- [ ] Page title in Instrument Serif?
|
||||||
@@ -470,12 +485,15 @@ Every diagram ships in three variants (see `assets/`):
|
|||||||
|
|
||||||
**Terminal variant** (optional, replaces any of the above) — see [primitive-terminal.md](references/primitive-terminal.md). `template-terminal.html`, `example-<type>-terminal.html`. Charcoal-black CLI-window chrome, monospace type, one red-orange accent. Good for dev-tool / CLI-product posts and technical social cards; not brand-tokenized, so skip it for onboarded/brand-matched output.
|
**Terminal variant** (optional, replaces any of the above) — see [primitive-terminal.md](references/primitive-terminal.md). `template-terminal.html`, `example-<type>-terminal.html`. Charcoal-black CLI-window chrome, monospace type, one red-orange accent. Good for dev-tool / CLI-product posts and technical social cards; not brand-tokenized, so skip it for onboarded/brand-matched output.
|
||||||
|
|
||||||
|
**Animation** (optional presentation layer) — see [animation.md](references/animation.md). Modes are `none` (default), `reveal`, `step`, and `loop`; motion never changes the static meaning or raises the complexity budget.
|
||||||
|
|
||||||
### To create a new diagram
|
### To create a new diagram
|
||||||
|
|
||||||
1. Copy the variant closest to what you want (`template.html` for minimal, `template-full.html` for cards).
|
1. Copy the variant closest to what you want (`template.html` for minimal, `template-full.html` for cards, `template-motion.html` only when motion is requested).
|
||||||
2. Load the matching `references/type-<name>.md` for layout conventions.
|
2. If behavior is load-bearing, choose a semantic pattern; then load the matching `references/type-<name>.md`.
|
||||||
3. Replace the eyebrow, h1, and SVG body. Replace `[diagram-slug]` with the file's diagram/variant slug, fill the copied `<title>` / `<desc>` placeholders, and do not delete them.
|
3. Replace the eyebrow, h1, and SVG body. Replace `[diagram-slug]` with the file slug and fill `<title>` / `<desc>`.
|
||||||
4. Run the §9 taste gate.
|
4. If motion is requested, load `animation.md`; otherwise keep mode `none` and no script.
|
||||||
|
5. Run the §9 taste gate.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -516,9 +534,9 @@ Always produce a single self-contained `.html` file:
|
|||||||
|
|
||||||
- Embedded CSS (no external except Google Fonts)
|
- Embedded CSS (no external except Google Fonts)
|
||||||
- Inline SVG (no external images)
|
- Inline SVG (no external images)
|
||||||
- No JavaScript required
|
- Static by default; minimal inline JavaScript only for explicit animation controls/state
|
||||||
|
|
||||||
Renders correctly in any modern browser.
|
Renders correctly in any modern browser. Motion-enabled output must render its complete meaning without JavaScript; under `prefers-reduced-motion: reduce` it shows the complete static frame and hides/disables playback controls.
|
||||||
|
|
||||||
### Accessible SVG contract
|
### Accessible SVG contract
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Where policy traces diverge</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
:root {
|
||||||
|
--paper: #f5f5f5;
|
||||||
|
--paper-2: #ececec;
|
||||||
|
--ink: #2d3142;
|
||||||
|
--muted: #4f5d75;
|
||||||
|
--soft: #7a8399;
|
||||||
|
--rule: rgba(45,49,66,0.12);
|
||||||
|
--rule-strong: rgba(45,49,66,0.30);
|
||||||
|
--accent: #eb6c36;
|
||||||
|
--accent-tint: rgba(235,108,54,0.08);
|
||||||
|
--sans: 'Geist', system-ui, sans-serif;
|
||||||
|
--serif: 'Instrument Serif', serif;
|
||||||
|
--mono: 'Geist Mono', ui-monospace, monospace;
|
||||||
|
--motion-fast: 160ms;
|
||||||
|
--motion-step: 480ms;
|
||||||
|
--motion-hold: 720ms;
|
||||||
|
--motion-ease: cubic-bezier(.2,.8,.2,1);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 32px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--paper);
|
||||||
|
font-family: var(--sans);
|
||||||
|
}
|
||||||
|
[data-motion-root] {
|
||||||
|
width: min(100%, 1280px);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 32px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font: 500 8px/1.4 var(--mono);
|
||||||
|
letter-spacing: .18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font: 400 clamp(28px, 3vw, 40px)/1.08 var(--serif);
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
}
|
||||||
|
.lede {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.diagram-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
svg { display: block; width: 100%; min-width: 880px; }
|
||||||
|
|
||||||
|
[data-motion-controls] {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 60px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.motion-ready [data-motion-controls] { display: flex; }
|
||||||
|
[data-motion-controls][hidden] { display: none !important; }
|
||||||
|
[data-motion-controls] button {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--paper);
|
||||||
|
font: 600 12px/1 var(--sans);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
[data-motion-controls] button:hover { border-color: var(--accent); }
|
||||||
|
[data-motion-controls] button:focus-visible,
|
||||||
|
[data-motion-root]:focus-visible {
|
||||||
|
outline: 3px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
[data-motion-controls] button:disabled { cursor: not-allowed; opacity: .45; }
|
||||||
|
.keyboard-help {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--soft);
|
||||||
|
font: 500 9px/1.4 var(--mono);
|
||||||
|
}
|
||||||
|
[data-motion-status] {
|
||||||
|
margin-left: auto;
|
||||||
|
min-width: 272px;
|
||||||
|
padding-left: 16px;
|
||||||
|
border-left: 1px solid var(--rule);
|
||||||
|
color: var(--muted);
|
||||||
|
font: 500 10px/1.4 var(--mono);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
noscript p {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font: 500 10px/1.4 var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The source frame is complete. Hiding begins only after user activation. */
|
||||||
|
.motion-ready [data-motion-item] {
|
||||||
|
opacity: .12;
|
||||||
|
transform: translateY(8px);
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
transition: opacity var(--motion-step) var(--motion-ease),
|
||||||
|
transform var(--motion-step) var(--motion-ease);
|
||||||
|
}
|
||||||
|
.motion-ready [data-motion-item].is-visible,
|
||||||
|
.motion-ready[data-frame="end"] [data-motion-item],
|
||||||
|
.motion-ready[data-frame="static"] [data-motion-item] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.row-focus { fill: transparent; stroke: transparent; stroke-width: 1.2; }
|
||||||
|
.motion-ready [data-motion-item].is-current .row-focus {
|
||||||
|
stroke: var(--muted);
|
||||||
|
stroke-dasharray: 4 4;
|
||||||
|
}
|
||||||
|
html[data-motion="static"] [data-motion-controls] { display: none !important; }
|
||||||
|
html[data-motion="static"] [data-motion-item] {
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: .001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: .001ms !important;
|
||||||
|
}
|
||||||
|
[data-motion-item] { opacity: 1 !important; transform: none !important; }
|
||||||
|
[data-motion-decorative] { display: none !important; }
|
||||||
|
[data-motion-controls] { display: none !important; }
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
body { padding: 0; }
|
||||||
|
[data-motion-controls], [data-motion-decorative] { display: none !important; }
|
||||||
|
[data-motion-item] { opacity: 1 !important; transform: none !important; }
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
body { padding: 20px; }
|
||||||
|
.page-header { display: block; }
|
||||||
|
.lede { margin-top: 12px; }
|
||||||
|
[data-motion-status] { min-width: 200px; }
|
||||||
|
.keyboard-help { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Use ?motion=static for deterministic screenshots and export. -->
|
||||||
|
<main data-motion-root data-motion-mode="step" data-step-count="5" data-step-current="5"
|
||||||
|
data-frame="static" data-static-frame="complete" tabindex="-1">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Semantic pattern · Paired policy-evaluation traces</p>
|
||||||
|
<h1>Where policy traces diverge</h1>
|
||||||
|
</div>
|
||||||
|
<p class="lede">Two requests traverse the same ordered rules. Explicit states distinguish a rule that does not apply from one the evaluator never reaches.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="diagram-wrap">
|
||||||
|
<svg viewBox="0 0 1200 660" xmlns="http://www.w3.org/2000/svg" role="img"
|
||||||
|
aria-labelledby="policy-trace-animated-title policy-trace-animated-desc"
|
||||||
|
data-motion-primitive="policy-evaluation">
|
||||||
|
<title id="policy-trace-animated-title">Where two policy evaluations first diverge</title>
|
||||||
|
<desc id="policy-trace-animated-desc">Two requests pass identity and audience checks. At the data-class rule, the read-only request passes while the sensitive-write request fails; later rules are skipped or not reached, producing permitted and denied outcomes.</desc>
|
||||||
|
<rect width="1200" height="660" fill="#f5f5f5"/>
|
||||||
|
|
||||||
|
<!-- Persistent trace paths are meaningful in every frame. -->
|
||||||
|
<line x1="620" y1="104" x2="620" y2="576" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
|
||||||
|
<line data-trace-connector="trace-b" x1="936" y1="104" x2="936" y2="336" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- Shared rule column and trace headers. -->
|
||||||
|
<text x="72" y="76" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="12" letter-spacing="1.6">ORDERED POLICY</text>
|
||||||
|
<rect x="500" y="44" width="240" height="60" rx="6" fill="#ffffff" stroke="#2d3142"/>
|
||||||
|
<text x="620" y="72" text-anchor="middle" fill="#2d3142" font-family="'Geist', sans-serif" font-size="12" font-weight="600">Trace A · read-only</text>
|
||||||
|
<text x="620" y="92" text-anchor="middle" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="12">internal metrics request</text>
|
||||||
|
<rect x="816" y="44" width="240" height="60" rx="6" fill="#ffffff" stroke="#2d3142"/>
|
||||||
|
<text x="936" y="72" text-anchor="middle" fill="#2d3142" font-family="'Geist', sans-serif" font-size="12" font-weight="600">Trace B · sensitive write</text>
|
||||||
|
<text x="936" y="92" text-anchor="middle" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="12">restricted records request</text>
|
||||||
|
|
||||||
|
<g data-motion-item data-step="1" aria-label="Rule 1: Identity bound passes for both traces">
|
||||||
|
<rect class="row-focus" x="48" y="124" width="1080" height="68" rx="4"/>
|
||||||
|
<text x="72" y="152" fill="#2d3142" font-family="'Geist', sans-serif" font-size="12" font-weight="600">1 · Identity bound</text>
|
||||||
|
<text x="72" y="172" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="12">named principal required</text>
|
||||||
|
<rect x="532" y="132" width="176" height="52" rx="4" fill="rgba(45,49,66,0.05)" stroke="#4f5d75"/>
|
||||||
|
<text x="620" y="164" text-anchor="middle" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="10" font-weight="600">✓ PASS</text>
|
||||||
|
<rect x="848" y="132" width="176" height="52" rx="4" fill="rgba(45,49,66,0.05)" stroke="#4f5d75"/>
|
||||||
|
<text x="936" y="164" text-anchor="middle" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="10" font-weight="600">✓ PASS</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g data-motion-item data-step="2" aria-label="Rule 2: Internal audience passes for both traces">
|
||||||
|
<rect class="row-focus" x="48" y="200" width="1080" height="68" rx="4"/>
|
||||||
|
<text x="72" y="228" fill="#2d3142" font-family="'Geist', sans-serif" font-size="12" font-weight="600">2 · Internal audience</text>
|
||||||
|
<text x="72" y="248" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="12">employees only</text>
|
||||||
|
<rect x="532" y="208" width="176" height="52" rx="4" fill="rgba(45,49,66,0.05)" stroke="#4f5d75"/>
|
||||||
|
<text x="620" y="240" text-anchor="middle" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="10" font-weight="600">✓ PASS</text>
|
||||||
|
<rect x="848" y="208" width="176" height="52" rx="4" fill="rgba(45,49,66,0.05)" stroke="#4f5d75"/>
|
||||||
|
<text x="936" y="240" text-anchor="middle" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="10" font-weight="600">✓ PASS</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g data-motion-item data-step="3" aria-label="Rule 3 is the first divergence: Trace A passes and Trace B fails">
|
||||||
|
<rect class="row-focus" x="48" y="276" width="1080" height="80" rx="4"/>
|
||||||
|
<text x="72" y="304" fill="#2d3142" font-family="'Geist', sans-serif" font-size="12" font-weight="600">3 · Data class</text>
|
||||||
|
<text x="72" y="324" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="12">restricted writes denied</text>
|
||||||
|
<rect x="532" y="284" width="176" height="52" rx="4" fill="rgba(45,49,66,0.05)" stroke="#4f5d75"/>
|
||||||
|
<text x="620" y="316" text-anchor="middle" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="10" font-weight="600">✓ PASS</text>
|
||||||
|
<rect data-trace-b-terminal="fail" x="848" y="284" width="176" height="52" rx="4" fill="rgba(235,108,54,0.08)" stroke="#eb6c36" stroke-width="1.2"/>
|
||||||
|
<text x="936" y="316" text-anchor="middle" fill="#eb6c36" font-family="'Geist Mono', monospace" font-size="10" font-weight="600">× FAIL</text>
|
||||||
|
<path d="M716 344 V352 H840 V344" fill="none" stroke="#eb6c36" stroke-width="1.2"/>
|
||||||
|
<rect x="712" y="336" width="132" height="20" rx="2" fill="#f5f5f5"/>
|
||||||
|
<text x="778" y="351" text-anchor="middle" fill="#eb6c36" font-family="'Geist Mono', monospace" font-size="12" font-weight="600" letter-spacing=".8">FIRST DIVERGENCE</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g data-motion-item data-step="4" aria-label="Rule 4 is skipped for Trace A and not reached for Trace B">
|
||||||
|
<rect class="row-focus" x="48" y="364" width="1080" height="68" rx="4"/>
|
||||||
|
<text x="72" y="392" fill="#2d3142" font-family="'Geist', sans-serif" font-size="12" font-weight="600">4 · Human approval</text>
|
||||||
|
<text x="72" y="412" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="12">required only for writes</text>
|
||||||
|
<rect x="532" y="372" width="176" height="52" rx="4" fill="#f5f5f5" stroke="#7a8399" stroke-dasharray="4 4"/>
|
||||||
|
<text x="620" y="404" text-anchor="middle" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="10" font-weight="600">— SKIPPED</text>
|
||||||
|
<rect data-trace-b-state="not-reached" x="848" y="372" width="176" height="52" rx="4" fill="rgba(45,49,66,0.02)" stroke="rgba(45,49,66,0.30)" stroke-dasharray="4 4"/>
|
||||||
|
<text x="936" y="404" text-anchor="middle" fill="#7a8399" font-family="'Geist Mono', monospace" font-size="10" font-weight="600">○ NOT REACHED</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g data-motion-item data-step="5" aria-label="Rule 5 passes for Trace A and is not reached for Trace B; outcomes are permitted and denied">
|
||||||
|
<rect class="row-focus" x="48" y="440" width="1080" height="152" rx="4"/>
|
||||||
|
<text x="72" y="468" fill="#2d3142" font-family="'Geist', sans-serif" font-size="12" font-weight="600">5 · Deploy gate</text>
|
||||||
|
<text x="72" y="488" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="12">all reached rules must pass</text>
|
||||||
|
<rect x="532" y="448" width="176" height="52" rx="4" fill="rgba(45,49,66,0.05)" stroke="#4f5d75"/>
|
||||||
|
<text x="620" y="480" text-anchor="middle" fill="#2d3142" font-family="'Geist Mono', monospace" font-size="10" font-weight="600">✓ PASS</text>
|
||||||
|
<rect data-trace-b-state="not-reached" x="848" y="448" width="176" height="52" rx="4" fill="rgba(45,49,66,0.02)" stroke="rgba(45,49,66,0.30)" stroke-dasharray="4 4"/>
|
||||||
|
<text x="936" y="480" text-anchor="middle" fill="#7a8399" font-family="'Geist Mono', monospace" font-size="10" font-weight="600">○ NOT REACHED</text>
|
||||||
|
|
||||||
|
<rect x="500" y="528" width="240" height="56" rx="6" fill="#2d3142" stroke="#2d3142"/>
|
||||||
|
<text x="620" y="552" text-anchor="middle" fill="#f5f5f5" font-family="'Geist Mono', monospace" font-size="12" letter-spacing="1.2">OUTCOME</text>
|
||||||
|
<text x="620" y="572" text-anchor="middle" fill="#f5f5f5" font-family="'Geist', sans-serif" font-size="12" font-weight="600">Permitted</text>
|
||||||
|
<rect data-trace-b-outcome="denied" x="816" y="528" width="240" height="56" rx="6" fill="#f5f5f5"/>
|
||||||
|
<rect x="816" y="528" width="240" height="56" rx="6" fill="rgba(235,108,54,0.08)" stroke="#eb6c36" stroke-width="1.2"/>
|
||||||
|
<text x="936" y="552" text-anchor="middle" fill="#eb6c36" font-family="'Geist Mono', monospace" font-size="12" letter-spacing="1.2">OUTCOME</text>
|
||||||
|
<text x="936" y="572" text-anchor="middle" fill="#2d3142" font-family="'Geist', sans-serif" font-size="12" font-weight="600">Denied · review</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<line x1="72" y1="620" x2="1128" y2="620" stroke="rgba(45,49,66,0.12)"/>
|
||||||
|
<text x="72" y="640" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="12">✓ PASS</text>
|
||||||
|
<text x="176" y="640" fill="#eb6c36" font-family="'Geist Mono', monospace" font-size="12">× FAIL</text>
|
||||||
|
<text x="272" y="640" fill="#4f5d75" font-family="'Geist Mono', monospace" font-size="12">— SKIPPED · rule intentionally bypassed</text>
|
||||||
|
<text x="564" y="640" fill="#7a8399" font-family="'Geist Mono', monospace" font-size="12">○ NOT REACHED · earlier terminal result</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-motion-controls aria-label="Policy trace playback controls">
|
||||||
|
<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="pause" aria-pressed="true">Pause</button>
|
||||||
|
<button type="button" data-motion-action="next">Next</button>
|
||||||
|
<button type="button" data-motion-action="replay">Replay</button>
|
||||||
|
<span class="keyboard-help">←/→ step · Space play/pause · R replay · Home reset</span>
|
||||||
|
<span data-motion-status role="status" aria-live="polite" aria-atomic="true">Static frame · all 5 rules visible</span>
|
||||||
|
</div>
|
||||||
|
<noscript><p>Animation controls require JavaScript. The complete final policy trace is shown above.</p></noscript>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script data-diagram-controls>
|
||||||
|
(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const staticOverride = params.get('motion') === 'static' || document.documentElement.dataset.motion === 'static';
|
||||||
|
const requestedStep = params.get('step');
|
||||||
|
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
document.fonts.ready.then(() => { document.documentElement.dataset.fontsReady = 'true'; });
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-motion-root]').forEach((root) => {
|
||||||
|
const count = Number(root.dataset.stepCount);
|
||||||
|
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 items = [...root.querySelectorAll('[data-motion-item]')];
|
||||||
|
const labels = Array.from({ length: count + 1 }, (_, index) => index === 0
|
||||||
|
? 'Ready'
|
||||||
|
: items.filter((item) => !item.hasAttribute('data-motion-decorative') && Number(item.dataset.step) === index)
|
||||||
|
.map((item) => item.getAttribute('aria-label')).join('; '));
|
||||||
|
const controls = root.querySelector('[data-motion-controls]');
|
||||||
|
const status = root.querySelector('[data-motion-status]');
|
||||||
|
const visibleStep = root.querySelector('[data-motion-step-label]');
|
||||||
|
let step = count;
|
||||||
|
let timer = 0;
|
||||||
|
let playing = false;
|
||||||
|
let stepBeforeReduce = 0;
|
||||||
|
let resumeAfterReduce = root.dataset.motionMode === 'reveal';
|
||||||
|
|
||||||
|
const buttons = (action) => controls.querySelector(`[data-motion-action="${action}"]`);
|
||||||
|
const clearClock = () => { window.clearTimeout(timer); timer = 0; };
|
||||||
|
const announce = () => { status.textContent = `Step ${step} of ${count}: ${labels[step] || 'Complete'}`; };
|
||||||
|
const setControlsAvailable = (available) => {
|
||||||
|
controls.hidden = !available;
|
||||||
|
controls.setAttribute('aria-hidden', String(!available));
|
||||||
|
controls.dataset.playbackDisabled = String(!available);
|
||||||
|
controls.querySelectorAll('button').forEach((control) => { control.disabled = !available; });
|
||||||
|
};
|
||||||
|
|
||||||
|
function render(next, userInitiated = false) {
|
||||||
|
step = Math.max(0, Math.min(count, next));
|
||||||
|
root.dataset.stepCurrent = String(step);
|
||||||
|
root.dataset.frame = step === count ? 'end' : step === 0 ? 'start' : 'step';
|
||||||
|
items.forEach((item) => {
|
||||||
|
const itemStep = Number(item.dataset.step);
|
||||||
|
item.classList.toggle('is-visible', itemStep <= step);
|
||||||
|
item.classList.toggle('is-current', itemStep === step);
|
||||||
|
});
|
||||||
|
if (visibleStep) visibleStep.textContent = String(step);
|
||||||
|
const unavailable = controls.dataset.playbackDisabled === 'true';
|
||||||
|
buttons('prev').disabled = unavailable || step === 0;
|
||||||
|
buttons('next').disabled = unavailable || step === count;
|
||||||
|
if (userInitiated) announce();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause(userInitiated = false) {
|
||||||
|
clearClock();
|
||||||
|
playing = false;
|
||||||
|
root.classList.remove('is-playing');
|
||||||
|
buttons('play').setAttribute('aria-pressed', 'false');
|
||||||
|
buttons('pause').setAttribute('aria-pressed', 'true');
|
||||||
|
if (userInitiated) status.textContent = `Paused at step ${step} of ${count}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
pause(false);
|
||||||
|
root.dataset.frame = 'end';
|
||||||
|
status.textContent = `Complete · step ${count} of ${count}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
if (!playing) return;
|
||||||
|
if (step >= count) { finish(); return; }
|
||||||
|
render(step + 1, false);
|
||||||
|
if (step >= count) { finish(); return; }
|
||||||
|
timer = window.setTimeout(tick, 720);
|
||||||
|
}
|
||||||
|
|
||||||
|
function play(fromStart = false, userInitiated = false) {
|
||||||
|
clearClock();
|
||||||
|
if (fromStart || step >= count) render(0, userInitiated);
|
||||||
|
playing = true;
|
||||||
|
root.classList.add('is-playing');
|
||||||
|
buttons('play').setAttribute('aria-pressed', 'true');
|
||||||
|
buttons('pause').setAttribute('aria-pressed', 'false');
|
||||||
|
timer = window.setTimeout(tick, 720);
|
||||||
|
}
|
||||||
|
|
||||||
|
controls.addEventListener('click', (event) => {
|
||||||
|
const button = event.target.closest('[data-motion-action]');
|
||||||
|
if (!button) return;
|
||||||
|
const action = button.dataset.motionAction;
|
||||||
|
if (action === 'play') play(false, true);
|
||||||
|
if (action === 'pause') pause(true);
|
||||||
|
if (action === 'replay') play(true, true);
|
||||||
|
if (action === 'prev') { pause(false); render(step - 1, true); }
|
||||||
|
if (action === 'next') { pause(false); render(step + 1, true); }
|
||||||
|
});
|
||||||
|
|
||||||
|
root.addEventListener('keydown', (event) => {
|
||||||
|
if (event.target.matches('input, textarea, select, a[href]')) return;
|
||||||
|
const keys = { ArrowLeft: step - 1, ArrowRight: step + 1, Home: 0, End: count };
|
||||||
|
if (event.code === 'Space' && !event.target.closest('button')) {
|
||||||
|
event.preventDefault();
|
||||||
|
playing ? pause(true) : play(false, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key.toLowerCase() === 'r') {
|
||||||
|
event.preventDefault();
|
||||||
|
play(true, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(event.key in keys)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
pause(false);
|
||||||
|
render(keys[event.key], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden' && playing) pause(false);
|
||||||
|
});
|
||||||
|
reduce.addEventListener('change', (event) => {
|
||||||
|
if (event.matches && root.dataset.motionState !== 'reduced') {
|
||||||
|
stepBeforeReduce = step;
|
||||||
|
resumeAfterReduce = playing;
|
||||||
|
}
|
||||||
|
pause(false);
|
||||||
|
if (event.matches) {
|
||||||
|
setControlsAvailable(false);
|
||||||
|
render(count, false);
|
||||||
|
root.dataset.frame = 'static';
|
||||||
|
root.dataset.motionState = 'reduced';
|
||||||
|
status.textContent = 'Reduced motion · complete static frame · playback controls unavailable';
|
||||||
|
} else if (staticOverride || root.dataset.motionMode === 'none') {
|
||||||
|
setControlsAvailable(false);
|
||||||
|
render(count, false);
|
||||||
|
root.dataset.frame = 'static';
|
||||||
|
root.dataset.motionState = 'static';
|
||||||
|
status.textContent = 'Static frame · complete diagram · playback controls unavailable';
|
||||||
|
} else {
|
||||||
|
delete root.dataset.motionState;
|
||||||
|
if (exactStep !== null) {
|
||||||
|
setControlsAvailable(false);
|
||||||
|
render(exactStep, false);
|
||||||
|
root.dataset.frame = 'step';
|
||||||
|
root.dataset.motionState = 'test';
|
||||||
|
status.textContent = `Test frame · step ${step} of ${count} · playback controls unavailable`;
|
||||||
|
} else {
|
||||||
|
setControlsAvailable(true);
|
||||||
|
render(stepBeforeReduce, false);
|
||||||
|
status.textContent = `Ready · step ${step} of ${count}`;
|
||||||
|
if (resumeAfterReduce && root.dataset.motionMode === 'reveal') {
|
||||||
|
play(false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resumeAfterReduce = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
root.classList.add('motion-ready');
|
||||||
|
if (staticOverride) {
|
||||||
|
document.documentElement.dataset.motion = 'static';
|
||||||
|
pause(false);
|
||||||
|
setControlsAvailable(false);
|
||||||
|
render(count, false);
|
||||||
|
root.dataset.frame = 'static';
|
||||||
|
root.dataset.motionState = 'static';
|
||||||
|
status.textContent = 'Static frame · complete diagram · playback controls unavailable';
|
||||||
|
} else if (reduce.matches || root.dataset.motionMode === 'none') {
|
||||||
|
pause(false);
|
||||||
|
setControlsAvailable(false);
|
||||||
|
render(count, false);
|
||||||
|
root.dataset.frame = 'static';
|
||||||
|
root.dataset.motionState = reduce.matches ? 'reduced' : 'static';
|
||||||
|
status.textContent = reduce.matches
|
||||||
|
? 'Reduced motion · complete static frame · playback controls unavailable'
|
||||||
|
: 'Static frame · complete diagram · playback controls unavailable';
|
||||||
|
} else if (exactStep !== null) {
|
||||||
|
document.documentElement.dataset.motion = 'step';
|
||||||
|
pause(false);
|
||||||
|
setControlsAvailable(false);
|
||||||
|
render(exactStep, false);
|
||||||
|
root.dataset.frame = 'step';
|
||||||
|
root.dataset.motionState = 'test';
|
||||||
|
status.textContent = `Test frame · step ${step} of ${count} · playback controls unavailable`;
|
||||||
|
} else if (root.dataset.motionMode === 'reveal') {
|
||||||
|
setControlsAvailable(true);
|
||||||
|
render(0, false);
|
||||||
|
play(false, false);
|
||||||
|
} else {
|
||||||
|
pause(false);
|
||||||
|
setControlsAvailable(true);
|
||||||
|
render(0, false);
|
||||||
|
status.textContent = `Ready · step 0 of ${count}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Motion diagram template</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
:root {
|
||||||
|
--color-paper: #f5f5f5;
|
||||||
|
--color-ink: #2d3142;
|
||||||
|
--color-muted: #4f5d75;
|
||||||
|
--color-soft: #7a8399;
|
||||||
|
--color-accent: #eb6c36;
|
||||||
|
--color-rule: rgba(45,49,66,0.12);
|
||||||
|
--font-sans: 'Geist', system-ui, sans-serif;
|
||||||
|
--font-serif: 'Instrument Serif', serif;
|
||||||
|
--font-mono: 'Geist Mono', ui-monospace, monospace;
|
||||||
|
--motion-fast: 160ms;
|
||||||
|
--motion-step: 480ms;
|
||||||
|
--motion-hold: 720ms;
|
||||||
|
--motion-total: 4320ms;
|
||||||
|
--motion-ease: cubic-bezier(.2,.8,.2,1);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 48px 32px;
|
||||||
|
color: var(--color-ink);
|
||||||
|
background: var(--color-paper);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
[data-motion-root] { width: min(100%, 1000px); }
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font: 500 12px/1.4 var(--font-mono);
|
||||||
|
letter-spacing: .18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
h1 { margin: 0 0 24px; font: 400 32px/1.15 var(--font-serif); }
|
||||||
|
svg { display: block; width: 100%; min-width: 720px; }
|
||||||
|
|
||||||
|
/* Static source is complete. Only an initialized enhancement hides items. */
|
||||||
|
.motion-ready [data-motion-item] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
transition: opacity var(--motion-step) var(--motion-ease),
|
||||||
|
transform var(--motion-step) var(--motion-ease);
|
||||||
|
}
|
||||||
|
.motion-ready [data-motion-item].is-visible,
|
||||||
|
.motion-ready[data-frame="end"] [data-motion-item],
|
||||||
|
.motion-ready[data-frame="static"] [data-motion-item] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.draw-path {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--color-accent);
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-dasharray: 1;
|
||||||
|
stroke-dashoffset: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.motion-ready .draw-path.is-visible {
|
||||||
|
animation: draw-path var(--motion-step) linear forwards;
|
||||||
|
}
|
||||||
|
.flow-token { opacity: 0; pointer-events: none; }
|
||||||
|
.motion-ready.is-playing .flow-token.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
animation: token-flow 3.2s linear 1 forwards;
|
||||||
|
}
|
||||||
|
.motion-ready[data-motion-mode="loop"].is-playing .flow-token.is-visible {
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
}
|
||||||
|
@keyframes draw-path { to { stroke-dashoffset: 0; } }
|
||||||
|
@keyframes token-flow {
|
||||||
|
0% { transform: translateX(0); }
|
||||||
|
100% { transform: translateX(240px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-motion-controls] {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--color-rule);
|
||||||
|
}
|
||||||
|
.motion-ready [data-motion-controls] { display: flex; }
|
||||||
|
[data-motion-controls][hidden] { display: none !important; }
|
||||||
|
[data-motion-controls] button {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--color-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-ink);
|
||||||
|
background: var(--color-paper);
|
||||||
|
font: 600 12px/1 var(--font-sans);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
[data-motion-controls] button:hover { border-color: var(--color-accent); }
|
||||||
|
[data-motion-controls] button:focus-visible {
|
||||||
|
outline: 3px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
[data-motion-controls] button:disabled { cursor: not-allowed; opacity: .45; }
|
||||||
|
[data-motion-status-visible] {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font: 500 12px/1.4 var(--font-mono);
|
||||||
|
}
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0,0,0,0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="static"] [data-motion-controls],
|
||||||
|
html[data-motion="static"] [data-motion-decorative] { display: none !important; }
|
||||||
|
html[data-motion="static"] [data-motion-item] {
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: none !important;
|
||||||
|
animation: 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) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: .001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: .001ms !important;
|
||||||
|
}
|
||||||
|
[data-motion-item] { opacity: 1 !important; transform: none !important; }
|
||||||
|
[data-motion-decorative] { display: none !important; }
|
||||||
|
[data-motion-controls] { display: none !important; }
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
[data-motion-controls], [data-motion-decorative] { display: none !important; }
|
||||||
|
[data-motion-item] {
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Replace template-motion in IDs and prose when copying this file. -->
|
||||||
|
<main data-motion-root data-motion-mode="step" data-step-count="5" data-step-current="5" data-frame="static" data-static-frame="complete">
|
||||||
|
<p class="eyebrow">Process · Optional motion</p>
|
||||||
|
<h1>Request evaluation</h1>
|
||||||
|
|
||||||
|
<svg viewBox="0 0 960 480" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="template-motion-title template-motion-desc">
|
||||||
|
<title id="template-motion-title">Request evaluation</title>
|
||||||
|
<desc id="template-motion-desc">A request is validated, evaluated against a policy, queued, and appended to an audit log.</desc>
|
||||||
|
<defs>
|
||||||
|
<marker id="template-motion-arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||||||
|
<polygon points="0 0, 8 3, 0 6" fill="#4f5d75"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<rect width="960" height="480" fill="#f5f5f5"/>
|
||||||
|
|
||||||
|
<!-- Persistent connectors carry the static meaning. -->
|
||||||
|
<path d="M248 180 H356" fill="none" stroke="#4f5d75" marker-end="url(#template-motion-arrow)"/>
|
||||||
|
<path d="M556 180 H664" fill="none" stroke="#4f5d75" marker-end="url(#template-motion-arrow)"/>
|
||||||
|
<path d="M764 232 V328" fill="none" stroke="#4f5d75" marker-end="url(#template-motion-arrow)"/>
|
||||||
|
|
||||||
|
<g data-motion-item data-step="1" aria-label="Step 1: Request received">
|
||||||
|
<rect x="48" y="128" width="200" height="104" rx="8" fill="#f5f5f5" stroke="#2d3142"/>
|
||||||
|
<text x="148" y="172" text-anchor="middle" fill="#2d3142" font-family="Geist, sans-serif" font-size="16" font-weight="600">Request</text>
|
||||||
|
<text x="148" y="196" text-anchor="middle" fill="#4f5d75" font-family="Geist Mono, monospace" font-size="12">fields complete</text>
|
||||||
|
</g>
|
||||||
|
<g data-motion-item data-step="2" aria-label="Step 2: Policy passed">
|
||||||
|
<rect x="356" y="128" width="200" height="104" rx="8" fill="rgba(235,108,54,0.08)" stroke="#eb6c36"/>
|
||||||
|
<text x="456" y="168" text-anchor="middle" fill="#2d3142" font-family="Geist, sans-serif" font-size="16" font-weight="600">Policy</text>
|
||||||
|
<text x="456" y="196" text-anchor="middle" fill="#4f5d75" font-family="Geist Mono, monospace" font-size="12">✓ PASS · rule 2</text>
|
||||||
|
</g>
|
||||||
|
<g data-motion-item data-step="3" aria-label="Step 3: Three items queued">
|
||||||
|
<rect x="664" y="128" width="200" height="104" rx="8" fill="#f5f5f5" stroke="#2d3142"/>
|
||||||
|
<text x="764" y="164" text-anchor="middle" fill="#2d3142" font-family="Geist, sans-serif" font-size="16" font-weight="600">Queue</text>
|
||||||
|
<rect x="700" y="184" width="16" height="16" fill="#4f5d75"/><rect x="724" y="184" width="16" height="16" fill="#4f5d75"/><rect x="748" y="184" width="16" height="16" fill="#4f5d75"/>
|
||||||
|
<text x="796" y="196" fill="#4f5d75" font-family="Geist Mono, monospace" font-size="12">3 queued</text>
|
||||||
|
</g>
|
||||||
|
<g data-motion-item data-step="4" aria-label="Step 4: Audit entry appended">
|
||||||
|
<rect x="664" y="328" width="200" height="88" rx="8" fill="#f5f5f5" stroke="#2d3142"/>
|
||||||
|
<text x="764" y="364" text-anchor="middle" fill="#2d3142" font-family="Geist, sans-serif" font-size="16" font-weight="600">Audit log</text>
|
||||||
|
<text x="764" y="388" text-anchor="middle" fill="#4f5d75" font-family="Geist Mono, monospace" font-size="12">0042 · appended</text>
|
||||||
|
</g>
|
||||||
|
<g data-motion-item data-step="5" aria-label="Step 5: Evaluation complete">
|
||||||
|
<text x="48" y="376" fill="#2d3142" font-family="Geist, sans-serif" font-size="16" font-weight="600">Outcome: accepted and recorded</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Decorative duplicate path and token never carry unique meaning. -->
|
||||||
|
<path data-motion-item data-step="2" data-motion-decorative aria-hidden="true" focusable="false" pathLength="1" class="draw-path" d="M248 180 H356"/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div data-motion-controls aria-label="Diagram playback controls">
|
||||||
|
<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="pause" aria-pressed="true">Pause</button>
|
||||||
|
<button type="button" data-motion-action="next">Next</button>
|
||||||
|
<button type="button" data-motion-action="replay">Replay</button>
|
||||||
|
<span class="keyboard-help">Arrow keys step · Home/End jump</span>
|
||||||
|
<span data-motion-status-visible aria-hidden="true">Step <span data-motion-step-label>5</span> of 5</span>
|
||||||
|
</div>
|
||||||
|
<p class="sr-only" data-motion-status role="status" aria-live="polite" aria-atomic="true"></p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script data-diagram-controls>
|
||||||
|
(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const staticOverride = params.get('motion') === 'static' || document.documentElement.dataset.motion === 'static';
|
||||||
|
const requestedStep = params.get('step');
|
||||||
|
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
document.fonts.ready.then(() => { document.documentElement.dataset.fontsReady = 'true'; });
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-motion-root]').forEach((root) => {
|
||||||
|
const count = Number(root.dataset.stepCount);
|
||||||
|
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 items = [...root.querySelectorAll('[data-motion-item]')];
|
||||||
|
const labels = Array.from({ length: count + 1 }, (_, index) => index === 0
|
||||||
|
? 'Ready'
|
||||||
|
: items.filter((item) => !item.hasAttribute('data-motion-decorative') && Number(item.dataset.step) === index)
|
||||||
|
.map((item) => item.getAttribute('aria-label')).join('; '));
|
||||||
|
const controls = root.querySelector('[data-motion-controls]');
|
||||||
|
const status = root.querySelector('[data-motion-status]');
|
||||||
|
const visibleStep = root.querySelector('[data-motion-step-label]');
|
||||||
|
let step = count;
|
||||||
|
let timer = 0;
|
||||||
|
let playing = false;
|
||||||
|
let stepBeforeReduce = 0;
|
||||||
|
let resumeAfterReduce = root.dataset.motionMode === 'reveal';
|
||||||
|
|
||||||
|
const buttons = (action) => controls.querySelector(`[data-motion-action="${action}"]`);
|
||||||
|
const clearClock = () => { window.clearTimeout(timer); timer = 0; };
|
||||||
|
const announce = () => { status.textContent = `Step ${step} of ${count}: ${labels[step] || 'Complete'}`; };
|
||||||
|
const setControlsAvailable = (available) => {
|
||||||
|
controls.hidden = !available;
|
||||||
|
controls.setAttribute('aria-hidden', String(!available));
|
||||||
|
controls.dataset.playbackDisabled = String(!available);
|
||||||
|
controls.querySelectorAll('button').forEach((control) => { control.disabled = !available; });
|
||||||
|
};
|
||||||
|
|
||||||
|
function render(next, userInitiated = false) {
|
||||||
|
step = Math.max(0, Math.min(count, next));
|
||||||
|
root.dataset.stepCurrent = String(step);
|
||||||
|
root.dataset.frame = step === count ? 'end' : step === 0 ? 'start' : 'step';
|
||||||
|
items.forEach((item) => {
|
||||||
|
const itemStep = Number(item.dataset.step);
|
||||||
|
item.classList.toggle('is-visible', itemStep <= step);
|
||||||
|
item.classList.toggle('is-current', itemStep === step);
|
||||||
|
});
|
||||||
|
if (visibleStep) visibleStep.textContent = String(step);
|
||||||
|
const unavailable = controls.dataset.playbackDisabled === 'true';
|
||||||
|
buttons('prev').disabled = unavailable || step === 0;
|
||||||
|
buttons('next').disabled = unavailable || step === count;
|
||||||
|
if (userInitiated) announce();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause(userInitiated = false) {
|
||||||
|
clearClock();
|
||||||
|
playing = false;
|
||||||
|
root.classList.remove('is-playing');
|
||||||
|
buttons('play').setAttribute('aria-pressed', 'false');
|
||||||
|
buttons('pause').setAttribute('aria-pressed', 'true');
|
||||||
|
if (userInitiated) status.textContent = `Paused at step ${step} of ${count}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
pause(false);
|
||||||
|
root.dataset.frame = 'end';
|
||||||
|
status.textContent = `Complete · step ${count} of ${count}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
if (!playing) return;
|
||||||
|
if (step >= count) { finish(); return; }
|
||||||
|
render(step + 1, false);
|
||||||
|
if (step >= count) { finish(); return; }
|
||||||
|
timer = window.setTimeout(tick, 720);
|
||||||
|
}
|
||||||
|
|
||||||
|
function play(fromStart = false, userInitiated = false) {
|
||||||
|
clearClock();
|
||||||
|
if (fromStart || step >= count) render(0, userInitiated);
|
||||||
|
playing = true;
|
||||||
|
root.classList.add('is-playing');
|
||||||
|
buttons('play').setAttribute('aria-pressed', 'true');
|
||||||
|
buttons('pause').setAttribute('aria-pressed', 'false');
|
||||||
|
timer = window.setTimeout(tick, 720);
|
||||||
|
}
|
||||||
|
|
||||||
|
controls.addEventListener('click', (event) => {
|
||||||
|
const button = event.target.closest('[data-motion-action]');
|
||||||
|
if (!button) return;
|
||||||
|
const action = button.dataset.motionAction;
|
||||||
|
if (action === 'play') play(false, true);
|
||||||
|
if (action === 'pause') pause(true);
|
||||||
|
if (action === 'replay') play(true, true);
|
||||||
|
if (action === 'prev') { pause(false); render(step - 1, true); }
|
||||||
|
if (action === 'next') { pause(false); render(step + 1, true); }
|
||||||
|
});
|
||||||
|
|
||||||
|
root.addEventListener('keydown', (event) => {
|
||||||
|
if (event.target.matches('input, textarea, select, a[href]')) return;
|
||||||
|
const keys = { ArrowLeft: step - 1, ArrowRight: step + 1, Home: 0, End: count };
|
||||||
|
if (event.code === 'Space' && !event.target.closest('button')) {
|
||||||
|
event.preventDefault();
|
||||||
|
playing ? pause(true) : play(false, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key.toLowerCase() === 'r') {
|
||||||
|
event.preventDefault();
|
||||||
|
play(true, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(event.key in keys)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
pause(false);
|
||||||
|
render(keys[event.key], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden' && playing) pause(false);
|
||||||
|
});
|
||||||
|
reduce.addEventListener('change', (event) => {
|
||||||
|
if (event.matches && root.dataset.motionState !== 'reduced') {
|
||||||
|
stepBeforeReduce = step;
|
||||||
|
resumeAfterReduce = playing;
|
||||||
|
}
|
||||||
|
pause(false);
|
||||||
|
if (event.matches) {
|
||||||
|
setControlsAvailable(false);
|
||||||
|
render(count, false);
|
||||||
|
root.dataset.frame = 'static';
|
||||||
|
root.dataset.motionState = 'reduced';
|
||||||
|
status.textContent = 'Reduced motion · complete static frame · playback controls unavailable';
|
||||||
|
} else if (staticOverride || root.dataset.motionMode === 'none') {
|
||||||
|
setControlsAvailable(false);
|
||||||
|
render(count, false);
|
||||||
|
root.dataset.frame = 'static';
|
||||||
|
root.dataset.motionState = 'static';
|
||||||
|
status.textContent = 'Static frame · complete diagram · playback controls unavailable';
|
||||||
|
} else {
|
||||||
|
delete root.dataset.motionState;
|
||||||
|
if (exactStep !== null) {
|
||||||
|
setControlsAvailable(false);
|
||||||
|
render(exactStep, false);
|
||||||
|
root.dataset.frame = 'step';
|
||||||
|
root.dataset.motionState = 'test';
|
||||||
|
status.textContent = `Test frame · step ${step} of ${count} · playback controls unavailable`;
|
||||||
|
} else {
|
||||||
|
setControlsAvailable(true);
|
||||||
|
render(stepBeforeReduce, false);
|
||||||
|
status.textContent = `Ready · step ${step} of ${count}`;
|
||||||
|
if (resumeAfterReduce && root.dataset.motionMode === 'reveal') {
|
||||||
|
play(false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resumeAfterReduce = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
root.classList.add('motion-ready');
|
||||||
|
if (staticOverride) {
|
||||||
|
document.documentElement.dataset.motion = 'static';
|
||||||
|
pause(false);
|
||||||
|
setControlsAvailable(false);
|
||||||
|
render(count, false);
|
||||||
|
root.dataset.frame = 'static';
|
||||||
|
root.dataset.motionState = 'static';
|
||||||
|
status.textContent = 'Static frame · complete diagram · playback controls unavailable';
|
||||||
|
} else if (reduce.matches || root.dataset.motionMode === 'none') {
|
||||||
|
pause(false);
|
||||||
|
setControlsAvailable(false);
|
||||||
|
render(count, false);
|
||||||
|
root.dataset.frame = 'static';
|
||||||
|
root.dataset.motionState = reduce.matches ? 'reduced' : 'static';
|
||||||
|
status.textContent = reduce.matches
|
||||||
|
? 'Reduced motion · complete static frame · playback controls unavailable'
|
||||||
|
: 'Static frame · complete diagram · playback controls unavailable';
|
||||||
|
} else if (exactStep !== null) {
|
||||||
|
document.documentElement.dataset.motion = 'step';
|
||||||
|
pause(false);
|
||||||
|
setControlsAvailable(false);
|
||||||
|
render(exactStep, false);
|
||||||
|
root.dataset.frame = 'step';
|
||||||
|
root.dataset.motionState = 'test';
|
||||||
|
status.textContent = `Test frame · step ${step} of ${count} · playback controls unavailable`;
|
||||||
|
} else if (root.dataset.motionMode === 'reveal') {
|
||||||
|
setControlsAvailable(true);
|
||||||
|
render(0, false);
|
||||||
|
play(false, false);
|
||||||
|
} else {
|
||||||
|
pause(false);
|
||||||
|
setControlsAvailable(true);
|
||||||
|
render(0, false);
|
||||||
|
status.textContent = `Ready · step 0 of ${count}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Optional animation
|
||||||
|
|
||||||
|
Animation explains a complete static diagram; it never supplies missing meaning. Load this reference only when motion is explicitly requested or materially clarifies order, accumulation, evaluation, containment, or propagation. Otherwise use mode `none` and ship static HTML.
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
Choose one mode per figure with `data-motion-mode="none|reveal|step|loop"`.
|
||||||
|
|
||||||
|
| Mode | Behavior | Controls / implementation | Use |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `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 |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
Only `loop` repeats. Queue state, typing, field values, policy outcomes, containment, and audit entries use `reveal` or `step` and finish complete.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
2. **Stable capture.** Initial `data-frame="static"`, `?motion=static`, print, no-JS, and standalone SVG export expose the complete frame and hide controls/decorative tokens. Do not capture after an arbitrary delay.
|
||||||
|
3. **CSS owns presentation.** Use CSS transitions/keyframes for appearance and travel. Minimal inline JavaScript is allowed only to bind explicit controls, update step/state attributes, schedule deterministic steps, and update the dedicated live-status region. No fetches, markup injection, path measurement, or mutation of semantic diagram labels or values.
|
||||||
|
4. **One clock.** Use `--motion-fast: 160ms`, `--motion-step: 480ms`, `--motion-hold: 720ms`, and `--motion-total` ≤ `8000ms`; derive delays from integer steps. No randomness, springs, or transition-event timing.
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Semantic primitives
|
||||||
|
|
||||||
|
Every primitive has text, count, symbol, pattern, or outline in addition to color.
|
||||||
|
|
||||||
|
| Primitive | Mechanism | Static / reduced-motion result | Limit |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Path draw** | Decorative duplicate path with `pathLength="1"` and animated dash offset | Base labeled connector remains visible | ≤2 paths; one active |
|
||||||
|
| **Staggered reveal** (stage reveal) | `data-motion-item` + opacity/translate ≤8px | All stages visible | ≤8 steps, 12 items |
|
||||||
|
| **Queue counter** (queue accumulation) | Stable slots; item reveal plus visible numeric count | Final queue and count visible | ≤5 items; no reorder |
|
||||||
|
| **Typing / field population** | Full accessible string; clipped decorative overlay or labeled row reveal | Complete text/fields visible once | ≤32 typed chars or 6 fields |
|
||||||
|
| **Policy evaluation** (rule evaluation) | Ordered rule rows with text statuses and a current-row outline | Every state and outcome visible | 3–6 rules; 2 traces |
|
||||||
|
| **Flow token** | `aria-hidden` token on a fixed path | Token hidden; connector remains | One token; loop ≥3s |
|
||||||
|
| **Containment** | Reveal children, then persistent labeled boundary | Children and boundary visible | One boundary transition |
|
||||||
|
| **Audit append** | Chronological rows revealed; stable timestamp/sequence | Complete ordered log visible | ≤5 appended rows |
|
||||||
|
|
||||||
|
Do not animate layout coordinates, connector routes, `viewBox`, node dimensions, or semantic text. Avoid zoom, parallax, bounce, shake, glow, particles, and indefinite blinking.
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--motion-fast: 160ms;
|
||||||
|
--motion-step: 480ms;
|
||||||
|
--motion-hold: 720ms;
|
||||||
|
--motion-ease: cubic-bezier(.2,.8,.2,1);
|
||||||
|
}
|
||||||
|
.motion-ready [data-motion-item] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
transition: opacity var(--motion-step) var(--motion-ease),
|
||||||
|
transform var(--motion-step) var(--motion-ease);
|
||||||
|
}
|
||||||
|
.motion-ready [data-motion-item].is-visible,
|
||||||
|
.motion-ready[data-frame="end"] [data-motion-item] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
[data-motion-controls][hidden] { display: none !important; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
[data-motion-item] { opacity: 1 !important; transform: none !important; }
|
||||||
|
[data-motion-decorative] { display: none !important; }
|
||||||
|
[data-motion-controls] { display: none !important; }
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
[data-motion-controls], [data-motion-decorative] { display: none !important; }
|
||||||
|
[data-motion-item] { opacity: 1 !important; transform: none !important; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interactive controls and keyboard
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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]`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Reduced motion, color, and accessibility
|
||||||
|
|
||||||
|
- `prefers-reduced-motion: reduce` initializes at the complete static frame, disables and hides every playback control, hides decorative movement, and exposes `data-motion-state="reduced"` plus status text that playback is unavailable. It never presents partial-step announcements beside a complete frame.
|
||||||
|
- The SVG's `<title>` and `<desc>` describe the complete meaning, not the animation. Interaction instructions remain visible HTML text.
|
||||||
|
- Decorative overlays carry `aria-hidden="true" focusable="false"`. Semantic text exists once in the accessibility tree.
|
||||||
|
- State is never color-only: policy uses symbol + `PASS/FAIL/SKIPPED/NOT REACHED`; queues show counts; active stages use number/label/outline.
|
||||||
|
- Nothing flashes or changes luminance more than three times per second.
|
||||||
|
|
||||||
|
## Complexity and deterministic timing
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Export and verification
|
||||||
|
|
||||||
|
PNG and SVG exports are static final-state artifacts unless the user explicitly requests a named step. Before capture, open `?motion=static`, await `document.fonts.ready`, and assert `data-frame="static"`. SVG extraction omits HTML controls and scripts; source-visible semantic markup keeps the result complete.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/verify-motion.py path/to/animated-diagram.html
|
||||||
|
python3 scripts/test-verify-motion.py
|
||||||
|
python3 scripts/lint-skin.py path/to/animated-diagram.html
|
||||||
|
```
|
||||||
|
|
||||||
|
The verifier checks mode/state declarations, contiguous steps, motion budgets, complete SVG naming, no-JS source visibility, decorative accessibility, the full control set, live status, reduced-motion/print CSS, keyboard handling, page-hide pause, bounded static/test overrides, immediate final-step stop, and exact canonical-controller identity. Its adversarial tests mutate the canonical template to prove each failure is rejected.
|
||||||
|
|
||||||
|
Then verify in a browser:
|
||||||
|
|
||||||
|
1. Disable JavaScript: the complete diagram remains visible and meaningful.
|
||||||
|
2. Emulate `prefers-reduced-motion: reduce`: the final state is complete, playback controls are hidden and disabled, and the DOM status says playback is unavailable.
|
||||||
|
3. Use keyboard only: Tab reaches each native control; Enter/Space operate it; Left/Right/Home/End step without moving focus.
|
||||||
|
4. Pause, resume, and replay twice: ordering and final state are identical.
|
||||||
|
5. Capture `?motion=static` twice after `document.fonts.ready`: pixels are stable.
|
||||||
|
6. Print preview plus PNG/SVG export: controls and decorative tokens are absent, while all semantic labels and relationships remain.
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- Autoplay on load, viewport re-entry, or an endless semantic loop.
|
||||||
|
- A blank/partial no-JS or reduced-motion frame.
|
||||||
|
- Motion that rescues an over-dense or unlabeled static diagram.
|
||||||
|
- Pass/fail, queue fullness, or outcome encoded only by hue.
|
||||||
|
- Remote scripts, general application logic, runtime geometry, or duplicated semantic text.
|
||||||
|
- Capturing at wall-clock delay instead of the explicit static override.
|
||||||
@@ -48,7 +48,7 @@ Tools that don't fetch remote fonts at import time (offline Illustrator, some Fi
|
|||||||
|
|
||||||
## PNG export procedure
|
## PNG export procedure
|
||||||
|
|
||||||
Render **the original HTML** (not the extracted SVG) and screenshot only the `<svg>` element's bounding box. This keeps font loading reliable (already wired in the source HTML) while satisfying the "diagram only" rule. The PNG always has a **transparent background** (`omit_background=True`) so it can be placed on any slide or doc colour without a white halo.
|
Render **the original HTML** (not the extracted SVG) and screenshot only the `<svg>` element's bounding box. This keeps font loading reliable (already wired in the source HTML) while satisfying the "diagram only" rule. The PNG always has a **transparent background** (`omit_background=True`) so it can be placed on any slide or doc colour without a white halo. For motion-enabled HTML, append `?motion=static`, await `document.fonts.ready`, and assert the motion root has `data-frame="static"` before capture; never export at an arbitrary wall-clock delay.
|
||||||
|
|
||||||
### Detection
|
### Detection
|
||||||
|
|
||||||
@@ -131,6 +131,6 @@ If the target aspect ratio doesn't match the `viewBox` aspect ratio, say so and
|
|||||||
## What this command never does
|
## What this command never does
|
||||||
|
|
||||||
- Modifies the source HTML.
|
- Modifies the source HTML.
|
||||||
- Adds export buttons or `<script>` tags to generated diagrams (the "no JS in deliverables" rule in §11 still stands).
|
- Adds export buttons or `<script>` tags. Static diagrams remain script-free; an already motion-enabled source may retain the scoped controller from [`animation.md`](animation.md), but export never injects another controller.
|
||||||
- Auto-emits `.svg` or `.png` alongside HTML generation. Manual on every call.
|
- Auto-emits `.svg` or `.png` alongside HTML generation. Manual on every call.
|
||||||
- Embeds an HTML wrapper (cards, headers) into the SVG via `foreignObject`. Too fragile across renderers.
|
- Embeds an HTML wrapper (cards, headers) into the SVG via `foreignObject`. Too fragile across renderers.
|
||||||
|
|||||||
@@ -77,6 +77,18 @@ Read the rendered `font-family` stack of:
|
|||||||
|
|
||||||
If the site has only one family, keep the schematic defaults for the missing roles (Instrument Serif for title, Geist Mono for mono). Don't force-pick a mono font that isn't on the site.
|
If the site has only one family, keep the schematic defaults for the missing roles (Instrument Serif for title, Geist Mono for mono). Don't force-pick a mono font that isn't on the site.
|
||||||
|
|
||||||
|
### Exact-font gate for brand-matched output
|
||||||
|
|
||||||
|
Do not replace a detected brand family with `serif`, `system-ui`, or `ui-monospace` merely to make the file dependency-free. A public font is part of the visual system.
|
||||||
|
|
||||||
|
1. Record the computed family and weight used by the sampled heading, body, and technical-label elements.
|
||||||
|
2. Trace each family to its source: an existing Google Fonts stylesheet, an installed/system stack, or a custom-hosted `@font-face`.
|
||||||
|
3. If it is available through Google Fonts, carry the exact family name, weights, and approved stylesheet into the style guide and generated HTML. The single-file allowlist accepts only a parsed HTTPS URL whose hostname is exactly `fonts.googleapis.com` and whose path is exactly `/css2`; prefix/lookalike hosts and other paths fail. Preserve an intentional system stack in order and verify the resolved family on the target machine.
|
||||||
|
4. A custom-hosted or paid font is not compatible with the default single-file allowlist. Label that role `fallback` unless the user separately approves and packages the font; never silently add a remote font URL or claim an exact match.
|
||||||
|
5. Verify the rendered output with `getComputedStyle`; a declared family that failed to load does not pass.
|
||||||
|
|
||||||
|
For a page containing bespoke diagrams or editorial figures, inspect their rendered font roles as well as the surrounding article. A figure-specific stylesheet may intentionally differ from the site's global heading/body stack.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 3 — map to semantic roles
|
## Step 3 — map to semantic roles
|
||||||
@@ -120,6 +132,16 @@ Show the user what will change in `style-guide.md`. Only the tokens table — ev
|
|||||||
|
|
||||||
Also regenerate the dark variant via the inversion rule (`rgba(11,13,11, X)` → `rgba(ink-rgb, X)`).
|
Also regenerate the dark variant via the inversion rule (`rgba(11,13,11, X)` → `rgba(ink-rgb, X)`).
|
||||||
|
|
||||||
|
Include a compact **brand fidelity receipt** with the preview:
|
||||||
|
|
||||||
|
- sampled URLs;
|
||||||
|
- detected paper, ink, muted, accent, surface, and rule values;
|
||||||
|
- title, body, and technical-label families with weights and source URLs;
|
||||||
|
- `exact` or `fallback` for each font role;
|
||||||
|
- any page-specific figure styling that should override the global site skin.
|
||||||
|
|
||||||
|
The receipt is required when the user says “match this site,” “use their branding,” or provides a page as the visual reference.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 5 — apply
|
## Step 5 — apply
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Semantic patterns
|
||||||
|
|
||||||
|
Semantic patterns describe **what a system does**; the 27 visual types describe **how information is arranged**. Choose a pattern first when behavior, state, enforcement, or risk is load-bearing, then use its nearest visual type as the layout grammar. If no pattern matches, choose a visual type directly.
|
||||||
|
|
||||||
|
Use one primary pattern per figure. A second pattern may supply at most one supporting primitive; if both need full treatment, split overview and detail. Labels and outcomes must remain complete in a static frame.
|
||||||
|
|
||||||
|
## Routing table
|
||||||
|
|
||||||
|
| The reader must understand… | Semantic pattern | Nearest visual type |
|
||||||
|
|---|---|---|
|
||||||
|
| Many arrivals competing for finite service capacity | **Fan-in queue / bottleneck** | Data flow |
|
||||||
|
| Repeated questions, inputs, controls, and outputs across stages | **Stage framework with semantic slots** | Process |
|
||||||
|
| A loose conversation becoming a durable structured record | **Unstructured input → structured artifact** | Data flow |
|
||||||
|
| Why two policy decisions differ and where they first diverge | **Paired policy-evaluation traces** | Flowchart |
|
||||||
|
| Which routes cross a trust boundary and which routes are blocked | **Secure paved road** | Architecture |
|
||||||
|
| Which controls apply at each enforcement surface | **Governance / control catalog** | Layer stack |
|
||||||
|
| How defenses reduce risk and what risk remains | **Compensating security layers** | Layer stack |
|
||||||
|
|
||||||
|
## 1. Fan-in queue / bottleneck
|
||||||
|
|
||||||
|
**Selection triggers:** Several producers converge on one reviewer, service, gate, or constrained resource; the story depends on arrival rate, queue depth, wait, capacity, or backpressure.
|
||||||
|
|
||||||
|
**Required primitives:** Distinct sources; fanned ingress; an ordered queue with visible slots and count; a capacity/service-rate label; one constrained service point; admitted and deferred/rejected outcomes. Label units (`8/hour`, `3 slots`), not just “high.”
|
||||||
|
|
||||||
|
**Complexity budget:** ≤5 sources, ≤5 queue slots, one bottleneck, two outcomes, and ≤9 primary nodes. Aggregate excess sources as a named cohort.
|
||||||
|
|
||||||
|
**Anti-patterns:** Equal-width pipeline that hides contention; arrows merged before they can be traced; capacity implied only by box size; decorative pile-up; animation that changes item order; red alone meaning overloaded.
|
||||||
|
|
||||||
|
**Static fallback:** Show the representative final queue, numeric count/capacity, bottleneck label, and both outcome paths. A still must reveal why work waits.
|
||||||
|
|
||||||
|
**Nearest visual type:** **Data flow** by default; use **Process** when service stages, rather than sources, dominate.
|
||||||
|
|
||||||
|
## 2. Stage framework with semantic slots
|
||||||
|
|
||||||
|
**Selection triggers:** A lifecycle or operating model repeats the same semantic questions across stages, commonly Question, Input, Governance, and Output. Cross-stage comparability matters more than message timing.
|
||||||
|
|
||||||
|
**Required primitives:** Ordered stage headers; a consistent slot grid; explicit empty/not-applicable slots; stage-to-stage handoff; stable slot labels; one primary output per stage. Preserve slot order in every stage.
|
||||||
|
|
||||||
|
**Complexity budget:** 3–6 stages, 3–4 slot kinds, ≤20 populated cells, ≤2 lines per cell. Split detail when a cell needs prose.
|
||||||
|
|
||||||
|
**Anti-patterns:** Each stage invents a different internal layout; slot meaning encoded by position with no labels; fake precision from dozens of cells; confusing stage order with ownership lanes; shrinking text to keep one canvas.
|
||||||
|
|
||||||
|
**Static fallback:** Render the full stage × slot matrix with handoffs and explicit `—` or `Not applicable` entries. Do not depend on staged reveal to teach the schema.
|
||||||
|
|
||||||
|
**Nearest visual type:** **Process**; use **Swimlane** only when the repeated rows represent owners rather than semantic slots.
|
||||||
|
|
||||||
|
## 3. Unstructured input → structured artifact
|
||||||
|
|
||||||
|
**Selection triggers:** Dialogue, notes, prompts, or a rambling request are elicited, normalized, and written into a durable brief, ticket, record, schema, or other structured artifact.
|
||||||
|
|
||||||
|
**Required primitives:** Source utterance(s); clarifying questions; extracted field/value pairs; a named transformation; the durable artifact boundary; provenance links from representative statements to fields; missing/unknown state.
|
||||||
|
|
||||||
|
**Complexity budget:** ≤4 exchanges, ≤6 artifact fields, one transformation, and ≤3 provenance links. Show representative content, not a transcript.
|
||||||
|
|
||||||
|
**Anti-patterns:** “AI magic” sparkle between two boxes; artifact shown as another chat bubble; fields appearing without sources; inventing certainty for missing facts; typing animation as the only readable copy.
|
||||||
|
|
||||||
|
**Static fallback:** Show a short source excerpt beside the completed labeled artifact, with at least one provenance mapping and any unknown fields visible.
|
||||||
|
|
||||||
|
**Nearest visual type:** **Data flow**; use **Process** when elicitation has several ordered gates.
|
||||||
|
|
||||||
|
## 4. Paired policy-evaluation traces
|
||||||
|
|
||||||
|
**Selection triggers:** Two otherwise similar requests reach different outcomes; the reader needs rule-by-rule `PASS`, `FAIL`, `SKIPPED`, or `NOT REACHED` state and the first divergence.
|
||||||
|
|
||||||
|
**Required primitives:** The same ordered rules on both traces; explicit status text plus symbol/shape; inputs that differ; final outcomes; a labeled first-divergence marker; a distinction between `SKIPPED` (applicable flow intentionally bypassed) and `NOT REACHED` (evaluation stopped earlier).
|
||||||
|
|
||||||
|
**Complexity budget:** Exactly 2 traces, 3–6 rules, one first divergence, ≤12 status cells, and one outcome per trace. Move rule prose to notes if labels exceed one line.
|
||||||
|
|
||||||
|
**Anti-patterns:** Comparing two independently ordered flows; green/red dots without words; treating skipped and not-reached as synonyms; highlighting every difference; continuing a denied trace as if downstream rules ran.
|
||||||
|
|
||||||
|
**Static fallback:** Show all rule states and both outcomes at once; use a persistent bracket/line and label for the first divergence.
|
||||||
|
|
||||||
|
**Nearest visual type:** **Flowchart** for ordered decision logic; use **Sequence** only when messages between actors and time are also load-bearing.
|
||||||
|
|
||||||
|
## 5. Secure paved road
|
||||||
|
|
||||||
|
**Selection triggers:** A supported architecture creates a bounded route from intake/build to deployment; trust boundaries, privileged moments, permitted ingress, forbidden ingress, and approved versus blocked deploy paths are the point.
|
||||||
|
|
||||||
|
**Required primitives:** Labeled trust boundaries; actors and identities; permitted ingress with a positive text label; forbidden ingress terminating at the boundary; approved deployment path; blocked bypass path; privileged gate; isolated runtime; audit destination. Use different line styles and stop symbols in addition to color.
|
||||||
|
|
||||||
|
**Complexity budget:** ≤3 trust zones, ≤8 components, ≤10 paths, ≤2 forbidden paths, and one privileged gate. Split control detail into a catalog figure.
|
||||||
|
|
||||||
|
**Anti-patterns:** Dashed box called “security” with no route semantics; forbidden arrow crossing into the protected zone; secrets or identity implied but unlabeled; every component styled as trusted; a bypass path that visually rejoins the approved route.
|
||||||
|
|
||||||
|
**Static fallback:** Render every boundary and both permitted/forbidden routes. Blocked paths must visibly stop before entry or deployment.
|
||||||
|
|
||||||
|
**Nearest visual type:** **Architecture**.
|
||||||
|
|
||||||
|
## 6. Governance / control catalog
|
||||||
|
|
||||||
|
**Selection triggers:** A control inventory must be understood by where it is enforced: authoring, workspace, merge/CI, deploy/runtime, or another named surface. A single checklist would hide those enforcement points.
|
||||||
|
|
||||||
|
**Required primitives:** Enforcement-surface groups; named controls; enforcement actor (`code`, `platform`, `human`); timing (`write`, `merge`, `deploy`, `run`); bypassability or exception route; coverage/gap notation.
|
||||||
|
|
||||||
|
**Complexity budget:** 3–5 surfaces, 3–7 controls per surface, ≤24 controls total, and ≤3 attributes per control. Summarize counts only when the item list exists elsewhere.
|
||||||
|
|
||||||
|
**Anti-patterns:** 35 tiny pills; grouping by vague themes instead of enforcement point; mixing aspirations with enforced controls; icons without control names; claiming defense-in-depth without showing surface coverage.
|
||||||
|
|
||||||
|
**Static fallback:** Show the complete grouped catalog with surface headers and text labels for actor and enforcement timing; preserve gaps and exceptions.
|
||||||
|
|
||||||
|
**Nearest visual type:** **Layer stack**; use **DP security matrix** when role permissions, not enforcement surfaces, are the dominant comparison.
|
||||||
|
|
||||||
|
## 7. Compensating security layers
|
||||||
|
|
||||||
|
**Selection triggers:** No layer is perfect; each defense covers a failure left by the previous layer, and residual risk must visibly narrow, transfer, or remain through the stack.
|
||||||
|
|
||||||
|
**Required primitives:** Ordered threat/risk input; named defensive layers; each layer's mitigation; explicit limitation or escape; residual-risk carrier between layers; final residual risk and consequence/response. Use labels or decreasing measures, never area alone.
|
||||||
|
|
||||||
|
**Complexity budget:** 3–5 layers, one primary risk thread, ≤2 mitigations per layer, and one final residual-risk statement. Split multiple unrelated threats into separate figures.
|
||||||
|
|
||||||
|
**Anti-patterns:** Implying the final layer makes risk zero; equal opaque slabs with no propagation; treating audit as prevention; shrinking shapes without numeric or verbal meaning; reversing prevention/detection/recovery order without explanation.
|
||||||
|
|
||||||
|
**Static fallback:** Show the complete propagation chain: initial risk → mitigation → escaped risk at every layer → final residual risk and response.
|
||||||
|
|
||||||
|
**Nearest visual type:** **Layer stack**; use **Nested** when containment boundaries, rather than ordered compensation, carry the meaning.
|
||||||
|
|
||||||
|
## Composition rules
|
||||||
|
|
||||||
|
- The semantic pattern may specialize status, boundary, queue, or propagation primitives; the selected type still owns page axis, connector grammar, spacing, and type-specific limits.
|
||||||
|
- Apply the stricter of the pattern budget and visual-type budget. Semantic cells/statuses are not permission to exceed the nine-node overview target.
|
||||||
|
- Use stable text for states and outcomes. Color, motion, and position reinforce meaning but never carry it alone.
|
||||||
|
- Optional animation is a presentation layer, not another pattern. Load [`animation.md`](animation.md) only when motion is requested or materially clarifies ordered change.
|
||||||
Reference in New Issue
Block a user