feat: consolidate reviewed Diagram Design 2.6.0 release (#134)

* fix(commands): stop hardcoding the visual-type count

Integrates the final reviewed diff from PR #112.

* docs(high-level): correct effective width checklist

Integrates the final reviewed diff from PR #105.

* fix(commands): quote profile argument hint

Integrates the final reviewed diff from PR #126.

* test(sankey): gate conservation and ribbon geometry

Integrates the final reviewed diff from PR #103.

* feat(tooling): add doctor diagnostics gate

Integrates the final reviewed diff from PR #132.

* feat(scripts): lint diagrams as rendered

Integrates the final reviewed diff from PR #18.

* feat(types): add quantitative polar chart

Integrates the final reviewed diff from PR #101.

* chore(release): consolidate reviewed changes in 2.6.0

Documents every combined gate and synchronizes Claude, Codex, and Factory metadata for the 39-type release.

* fix(doctor): support installed-skill projects

---------

Co-authored-by: mysoniw <soniw1@gmail.com>
Co-authored-by: thang <76550342+thangldw@users.noreply.github.com>
Co-authored-by: YinShaokun <yinshaokun@didiglobal.com>
Co-authored-by: 0xDarkMatter <0xDarkMatter@users.noreply.github.com>
Co-authored-by: Rupam Pal <rupam.pal@euromonitor.com>
Co-authored-by: Yuvraj3905 <yuvraj202001@gmail.com>
This commit is contained in:
Cathryn Lavery
2026-08-20 12:21:43 -07:00
committed by GitHub
co-authored by mysoniw thang YinShaokun 0xDarkMatter Rupam Pal Yuvraj3905
parent 4b573616cb
commit b827385a17
42 changed files with 7090 additions and 130 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "diagram-design", "name": "diagram-design",
"metadata": { "metadata": {
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling." "description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling."
}, },
"owner": { "owner": {
"name": "Cathryn Lavery" "name": "Cathryn Lavery"
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "diagram-design", "name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.", "description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.5.20", "version": "2.6.0",
"author": { "author": {
"name": "Cathryn Lavery", "name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery" "url": "https://github.com/cathrynlavery"
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "diagram-design", "name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.", "description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.5.20", "version": "2.6.0",
"author": { "author": {
"name": "Cathryn Lavery", "name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery" "url": "https://github.com/cathrynlavery"
@@ -26,7 +26,7 @@
"interface": { "interface": {
"displayName": "Diagram Design", "displayName": "Diagram Design",
"shortDescription": "Create editorial technical and product diagrams as standalone HTML.", "shortDescription": "Create editorial technical and product diagrams as standalone HTML.",
"longDescription": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, Sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.", "longDescription": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, Sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"developerName": "Cathryn Lavery", "developerName": "Cathryn Lavery",
"category": "Productivity", "category": "Productivity",
"capabilities": [ "capabilities": [
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "diagram-design", "name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.", "description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.5.20", "version": "2.6.0",
"author": { "author": {
"name": "Cathryn Lavery", "name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery" "url": "https://github.com/cathrynlavery"
+65
View File
@@ -142,6 +142,14 @@ jobs:
id: skin id: skin
run: python scripts/lint-skin.py --all --baseline run: python scripts/lint-skin.py --all --baseline
- name: Verify quantitative polar chart
if: always()
id: polar
shell: bash
run: |
python scripts/test-verify-polar.py
python scripts/verify-polar.py
- name: Verify sequence OAuth specs - name: Verify sequence OAuth specs
if: always() if: always()
id: oauth id: oauth
@@ -164,6 +172,16 @@ jobs:
id: motion id: motion
run: python scripts/test-verify-motion.py run: python scripts/test-verify-motion.py
- name: Verify doctor diagnostics contract
if: always()
id: doctor
run: python scripts/verify-doctor.py
- name: Verify doctor diagnostics tests
if: always()
id: doctor_tests
run: python scripts/test-verify-doctor.py
- name: Verify docs and routing sync - name: Verify docs and routing sync
if: always() if: always()
id: docs_sync id: docs_sync
@@ -221,6 +239,46 @@ jobs:
id: slopegraph_tests id: slopegraph_tests
run: python scripts/test-verify-slopegraph.py run: python scripts/test-verify-slopegraph.py
- name: Verify sankey flow conservation
if: always()
id: sankey
run: python scripts/verify-sankey.py --all
- name: Verify sankey checker
if: always()
id: sankey_tests
run: python scripts/test-verify-sankey.py
# lint-render.py's oracle is pixels, so the browser is part of the contract:
# pin Playwright exactly, and let that pin choose the Chromium build. Bump
# both together, deliberately, by editing PLAYWRIGHT_VERSION.
#
# One platform only. Antialiasing and font fallback differ per OS, so running
# a pixel comparison across the whole matrix would compare six oracles rather
# than one, and the thresholds are calibrated on Linux Chromium. The other
# matrix legs still run every source-level gate.
- name: Install Playwright Chromium
if: always() && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
env:
PLAYWRIGHT_VERSION: "1.62.0"
shell: bash
run: |
pip install "playwright==${PLAYWRIGHT_VERSION}"
playwright install --with-deps chromium
python -c "import importlib.metadata as m; print('playwright', m.version('playwright'))"
# The checks before the sweep: a broken linter must not be able to report
# a clean repository.
- name: Verify render linter checks
if: always() && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
id: render_selftest
run: python scripts/lint-render.py --self-test
- name: Run render linter
if: always() && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
id: render
run: python scripts/lint-render.py --all
- name: Verify generated icon assets - name: Verify generated icon assets
if: always() if: always()
id: icons id: icons
@@ -247,10 +305,13 @@ jobs:
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
echo "| Color Palette & Skin Linter | ${{ steps.skin.outcome == 'success' && '✅ Passed' || (steps.skin.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Color Palette & Skin Linter | ${{ steps.skin.outcome == 'success' && '✅ Passed' || (steps.skin.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Quantitative Polar Chart | ${{ steps.polar.outcome == 'success' && '✅ Passed' || (steps.polar.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 "| 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 "| Optional Motion Contract | ${{ steps.motion.outcome == 'success' && '✅ Passed' || (steps.motion.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Doctor Diagnostics Contract | ${{ steps.doctor.outcome == 'success' && '✅ Passed' || (steps.doctor.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Doctor Diagnostics Tests | ${{ steps.doctor_tests.outcome == 'success' && '✅ Passed' || (steps.doctor_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Docs & Routing Sync | ${{ steps.docs_sync.outcome == 'success' && '✅ Passed' || (steps.docs_sync.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Docs & Routing Sync | ${{ steps.docs_sync.outcome == 'success' && '✅ Passed' || (steps.docs_sync.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Canonical Screenshot Freshness | ${{ steps.screenshots.outcome == 'success' && '✅ Passed' || (steps.screenshots.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Canonical Screenshot Freshness | ${{ steps.screenshots.outcome == 'success' && '✅ Passed' || (steps.screenshots.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Packaged Self-Check | ${{ steps.self_check.outcome == 'success' && '✅ Passed' || (steps.self_check.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Packaged Self-Check | ${{ steps.self_check.outcome == 'success' && '✅ Passed' || (steps.self_check.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
@@ -262,4 +323,8 @@ jobs:
echo "| Dumbbell Checker | ${{ steps.dumbbell_tests.outcome == 'success' && '✅ Passed' || (steps.dumbbell_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Dumbbell Checker | ${{ steps.dumbbell_tests.outcome == 'success' && '✅ Passed' || (steps.dumbbell_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Slopegraph Shared Scale | ${{ steps.slopegraph.outcome == 'success' && '✅ Passed' || (steps.slopegraph.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Slopegraph Shared Scale | ${{ steps.slopegraph.outcome == 'success' && '✅ Passed' || (steps.slopegraph.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Slopegraph Checker | ${{ steps.slopegraph_tests.outcome == 'success' && '✅ Passed' || (steps.slopegraph_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Slopegraph Checker | ${{ steps.slopegraph_tests.outcome == 'success' && '✅ Passed' || (steps.slopegraph_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Sankey Flow Conservation | ${{ steps.sankey.outcome == 'success' && '✅ Passed' || (steps.sankey.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Sankey Checker | ${{ steps.sankey_tests.outcome == 'success' && '✅ Passed' || (steps.sankey_tests.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
echo "| Render Linter Self-Test | ${{ steps.render_selftest.outcome == 'success' && '✅ Passed' || (steps.render_selftest.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Rendered Layout (paint oracle) | ${{ steps.render.outcome == 'success' && '✅ Passed' || (steps.render.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
+18 -4
View File
@@ -8,7 +8,7 @@ Please read [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) first. All contributions ar
## What this project is ## What this project is
Diagram Design is an agent skill (Claude Code, Codex, Pi) that produces editorial-quality diagrams as self-contained HTML files. The repo is documentation-first: `skills/diagram-design/SKILL.md` is the index, each of the 38 visual types has its own reference file, and the extractor scripts in `skills/diagram-design/scripts/` turn draw.io and Mermaid sources into a structured IR. Diagram Design is an agent skill (Claude Code, Codex, Pi) that produces editorial-quality diagrams as self-contained HTML files. The repo is documentation-first: `skills/diagram-design/SKILL.md` is the index, each of the 39 visual types has its own reference file, and the extractor scripts in `skills/diagram-design/scripts/` turn draw.io and Mermaid sources into a structured IR.
See [README.md](README.md) for the full picture, including the design system and the import/export flows. See [README.md](README.md) for the full picture, including the design system and the import/export flows.
@@ -46,13 +46,17 @@ The helper refuses to run if the Claude and Codex versions already differ. If an
| Semantic-pattern routing | `python3 scripts/verify-semantic-motion.py --markdown-only` | | Semantic-pattern routing | `python3 scripts/verify-semantic-motion.py --markdown-only` |
| Animated-example structure and accessibility | `python3 scripts/verify-semantic-motion.py --example-only` | | Animated-example structure and accessibility | `python3 scripts/verify-semantic-motion.py --example-only` |
| Skin conformance of every example and template (colors, fonts, a11y, assets, scripts) | `python3 scripts/lint-skin.py --all --baseline` | | Skin conformance of every example and template (colors, fonts, a11y, assets, scripts) | `python3 scripts/lint-skin.py --all --baseline` |
| Rendered-layout checker and shipped examples/templates | `python3 scripts/lint-render.py --self-test && python3 scripts/lint-render.py --all` |
| Quantitative polar encoding and variant parity | `python3 scripts/test-verify-polar.py && python3 scripts/verify-polar.py` |
| 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 && python3 scripts/test-verify-drawio-import.py` | | draw.io import path (real extractor vs fixtures + docs sync) | `python3 scripts/verify-drawio-import.py && python3 scripts/test-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` | | Optional motion contract (fallbacks, controls, budgets, determinism) | `python3 scripts/test-verify-motion.py` |
| Doctor diagnostics contract (env checks, script presence, routing wiring) | `python3 scripts/verify-doctor.py` |
| Doctor diagnostics adversarial tests | `python3 scripts/test-verify-doctor.py` |
| Every shipped motion template/example | `python3 scripts/verify-motion.py --shipped` | | Every shipped motion template/example | `python3 scripts/verify-motion.py --shipped` |
| Docs/routing sync (description hooks, gallery, README tree, reference links, strict-bundler support paths, profile surfaces) | `python3 scripts/verify-docs-sync.py && python3 scripts/test-verify-docs-sync.py` | | Docs/routing sync (description hooks, gallery, README tree, reference links, strict-bundler support paths, command/prompt surfaces) | `python3 scripts/verify-docs-sync.py && python3 scripts/test-verify-docs-sync.py` |
| Canonical README screenshots match their example HTML sources and recorded PNG digests | `python3 scripts/verify-screenshot-freshness.py` | | Canonical README screenshots match their example HTML sources and recorded PNG digests | `python3 scripts/verify-screenshot-freshness.py` |
| Packaged output self-check behaves (pass + adversarial cases) | `python3 scripts/test-self-check.py` | | Packaged output self-check behaves (pass + adversarial cases) | `python3 scripts/test-self-check.py` |
| Label masks are never clipped by a node painted after them | `python3 scripts/verify-geometry.py --all` | | Label masks are never clipped by a node painted after them | `python3 scripts/verify-geometry.py --all` |
@@ -63,6 +67,8 @@ The helper refuses to run if the Claude and Codex versions already differ. If an
| Dumbbell checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-dumbbell.py` | | Dumbbell checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-dumbbell.py` |
| Slopegraph axes share one scale and every endpoint matches its printed value | `python3 scripts/verify-slopegraph.py --all` | | Slopegraph axes share one scale and every endpoint matches its printed value | `python3 scripts/verify-slopegraph.py --all` |
| Slopegraph checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-slopegraph.py` | | Slopegraph checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-slopegraph.py` |
| Sankey flow conservation and ribbon geometry | `python3 scripts/verify-sankey.py --all` |
| Sankey checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-sankey.py` |
| Generated icon assets are up to date (`icons.html`, `primitive-icons.md`) | `python3 scripts/build-icons.py` then `git diff --exit-code` on the two generated files | | Generated icon assets are up to date (`icons.html`, `primitive-icons.md`) | `python3 scripts/build-icons.py` then `git diff --exit-code` on the two generated files |
The semantic-pattern gate also caps `skills/diagram-design/SKILL.md` at 40,000 bytes so the installed skill remains practical to load. If that gate fails, reduce duplication or move detail into a routed reference; do not remove routing vocabulary from frontmatter. The semantic-pattern gate also caps `skills/diagram-design/SKILL.md` at 40,000 bytes so the installed skill remains practical to load. If that gate fails, reduce duplication or move detail into a routed reference; do not remove routing vocabulary from frontmatter.
@@ -78,11 +84,17 @@ python3 scripts/test-plugin-package.py \
&& python3 scripts/verify-semantic-motion.py --example-only \ && python3 scripts/verify-semantic-motion.py --example-only \
&& python3 scripts/verify-motion.py --shipped \ && python3 scripts/verify-motion.py --shipped \
&& python3 scripts/lint-skin.py --all --baseline \ && python3 scripts/lint-skin.py --all --baseline \
&& python3 scripts/lint-render.py --self-test \
&& python3 scripts/lint-render.py --all \
&& python3 scripts/test-verify-polar.py \
&& python3 scripts/verify-polar.py \
&& 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/test-verify-drawio-import.py \ && python3 scripts/test-verify-drawio-import.py \
&& python3 scripts/verify-mermaid-import.py \ && python3 scripts/verify-mermaid-import.py \
&& python3 scripts/test-verify-motion.py \ && python3 scripts/test-verify-motion.py \
&& python3 scripts/verify-doctor.py \
&& python3 scripts/test-verify-doctor.py \
&& python3 scripts/verify-docs-sync.py \ && python3 scripts/verify-docs-sync.py \
&& python3 scripts/test-verify-docs-sync.py \ && python3 scripts/test-verify-docs-sync.py \
&& python3 scripts/verify-screenshot-freshness.py \ && python3 scripts/verify-screenshot-freshness.py \
@@ -94,7 +106,9 @@ python3 scripts/test-plugin-package.py \
&& python3 scripts/verify-dumbbell.py \ && python3 scripts/verify-dumbbell.py \
&& python3 scripts/test-verify-dumbbell.py \ && python3 scripts/test-verify-dumbbell.py \
&& python3 scripts/verify-slopegraph.py --all \ && python3 scripts/verify-slopegraph.py --all \
&& python3 scripts/test-verify-slopegraph.py && python3 scripts/test-verify-slopegraph.py \
&& python3 scripts/verify-sankey.py --all \
&& python3 scripts/test-verify-sankey.py
``` ```
### If a gate fails ### If a gate fails
@@ -102,7 +116,7 @@ python3 scripts/test-plugin-package.py \
- **`verify-plugin-package.py`:** run the bump helper if the versions did not increase. If packaging validation fails, keep both marketplaces pointed at the repository root and keep the shared skill at `skills/diagram-design/SKILL.md`. - **`verify-plugin-package.py`:** run the bump helper if the versions did not increase. If packaging validation fails, keep both marketplaces pointed at the repository root and keep the shared skill at `skills/diagram-design/SKILL.md`.
- **`lint-skin.py`:** the failure message names the file, line, and category (`color`, `font-family`, `a11y`, `external-asset`, `pure-black`, `script`). Colors must come from the palette in `skills/diagram-design/references/style-guide.md`; fonts from the allowed list; diagrams must satisfy the accessible SVG contract (see below). The linter also requires the SHA-pinned controller from `template-motion.html` verbatim and rejects remote resources, CSS `@import`, non-fragment CSS `url()`, event handlers, `srcdoc`, executable URLs, and extra scripts. - **`lint-skin.py`:** the failure message names the file, line, and category (`color`, `font-family`, `a11y`, `external-asset`, `pure-black`, `script`). Colors must come from the palette in `skills/diagram-design/references/style-guide.md`; fonts from the allowed list; diagrams must satisfy the accessible SVG contract (see below). The linter also requires the SHA-pinned controller from `template-motion.html` verbatim and rejects remote resources, CSS `@import`, non-fragment CSS `url()`, event handlers, `srcdoc`, executable URLs, and extra scripts.
- **`verify-*.py`:** the extractor's real behavior no longer matches its fixture or the documentation, or the reference/command/prompt wiring drifted. Fix the source of truth — do not widen a test to avoid a failure. - **`verify-*.py`:** the extractor's real behavior no longer matches its fixture or the documentation, or the reference/command/prompt wiring drifted. Fix the source of truth — do not widen a test to avoid a failure.
- **`verify-screenshot-freshness.py`:** a canonical minimal-light example or its committed PNG changed without a synchronized catalog refresh. Before the first regeneration, install the renderer with `python3 -m pip install playwright && python3 -m playwright install chromium`. Then run `python3 scripts/render-canonical-screenshots.py`, inspect all 38 renders, and commit the updated PNGs plus `docs/screenshots/manifest.json`. - **`verify-screenshot-freshness.py`:** a canonical minimal-light example or its committed PNG changed without a synchronized catalog refresh. Before the first regeneration, install the renderer with `python3 -m pip install playwright && python3 -m playwright install chromium`. Then run `python3 scripts/render-canonical-screenshots.py`, inspect all 39 renders, and commit the updated PNGs plus `docs/screenshots/manifest.json`.
- **`verify-slopegraph.py`:** the two axes disagree about scale or origin, or an endpoint is drawn somewhere other than where its own declared value belongs. Fix the coordinate, never the label — and never move a point to stop two endpoint labels colliding, because crowded labels mean the values really are close. - **`verify-slopegraph.py`:** the two axes disagree about scale or origin, or an endpoint is drawn somewhere other than where its own declared value belongs. Fix the coordinate, never the label — and never move a point to stop two endpoint labels colliding, because crowded labels mean the values really are close.
- **`verify-geometry.py`:** a label mask overlaps a node declared later in the document, so the node fill clips the label at render time. Move the label to a free segment of its connector — keep the 6–10px gap from the stroke required by SKILL.md §6, and do not shrink the mask to sneak under the check. - **`verify-geometry.py`:** a label mask overlaps a node declared later in the document, so the node fill clips the label at render time. Move the label to a free segment of its connector — keep the 6–10px gap from the stroke required by SKILL.md §6, and do not shrink the mask to sneak under the check.
- **Icon assets:** you changed `scripts/vendor/icons/` or `scripts/build-icons.py` and the generated files went stale. Rerun `python3 scripts/build-icons.py` and commit the regenerated files. - **Icon assets:** you changed `scripts/vendor/icons/` or `scripts/build-icons.py` and the generated files went stale. Rerun `python3 scripts/build-icons.py` and commit the regenerated files.
+61 -15
View File
@@ -14,7 +14,7 @@
*New in 2.5.10: ten more layout grammars — Sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, and database schema.* *New in 2.5.10: ten more layout grammars — Sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, and database schema.*
38 editorial diagram types for Claude Code, Codex, Factory Droid, and Pi. Self-contained HTML + SVG. No shadows. No Mermaid slop. 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. 39 editorial diagram types for Claude Code, Codex, Factory Droid, and Pi. Self-contained HTML + SVG. No shadows. No Mermaid slop. 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.
@@ -24,7 +24,7 @@ No Figma. No generic rounded boxes. No 30-minute color-picking sessions.
I write at [littlemight.com](https://littlemight.com?utm_source=diagram-design&utm_medium=readme&utm_campaign=github&utm_content=intro) (and run [BestSelf.co](https://bestself.co?utm_source=diagram-design&utm_medium=readme&utm_campaign=github&utm_content=intro) on the side). Every time I needed a diagram — an architecture sketch, a flowchart, a pyramid of what matters most — I'd ask Claude and get back a generic rounded-box thing that looked nothing like the rest of the site. I'd either fight with Figma for 30 minutes or just skip the diagram. I write at [littlemight.com](https://littlemight.com?utm_source=diagram-design&utm_medium=readme&utm_campaign=github&utm_content=intro) (and run [BestSelf.co](https://bestself.co?utm_source=diagram-design&utm_medium=readme&utm_campaign=github&utm_content=intro) on the side). Every time I needed a diagram — an architecture sketch, a flowchart, a pyramid of what matters most — I'd ask Claude and get back a generic rounded-box thing that looked nothing like the rest of the site. I'd either fight with Figma for 30 minutes or just skip the diagram.
So I built a Claude Code skill for it. Thirty-eight visual types, editorial quality, matches your brand in 60 seconds by reading your website. So I built a Claude Code skill for it. Thirty-nine visual types, editorial quality, matches your brand in 60 seconds by reading your website.
> *The highest-quality move is usually deletion.* Every node earns its place. The accent color is reserved for the 1–2 things the reader should look at first. Target density: 4/10. > *The highest-quality move is usually deletion.* Every node earns its place. The accent color is reserved for the 1–2 things the reader should look at first. Target density: 4/10.
@@ -32,7 +32,7 @@ So I built a Claude Code skill for it. Thirty-eight visual types, editorial qual
## What it makes ## What it makes
All 38 visual types ship in three static variants: minimal light, minimal dark, and full-editorial. Open any of them directly in a browser. There is no build step, JavaScript, or external image dependency. All 39 visual types ship in three static variants: minimal light, minimal dark, and full-editorial. Open any of them directly in a browser. There is no build step, JavaScript, or external image dependency.
<table> <table>
<tr> <tr>
@@ -98,13 +98,13 @@ All 38 visual types ship in three static variants: minimal light, minimal dark,
<tr> <tr>
<td align="center"><img src="docs/screenshots/story-map.png" alt="Story map"><br><b>Story map</b><br><sub>Backbone × release slices</sub></td> <td align="center"><img src="docs/screenshots/story-map.png" alt="Story map"><br><b>Story map</b><br><sub>Backbone × release slices</sub></td>
<td align="center"><img src="docs/screenshots/db-schema.png" alt="Database schema"><br><b>Database schema</b><br><sub>Physical tables + column FKs</sub></td> <td align="center"><img src="docs/screenshots/db-schema.png" alt="Database schema"><br><b>Database schema</b><br><sub>Physical tables + column FKs</sub></td>
<td></td> <td align="center"><img src="docs/screenshots/polar.png" alt="Polar chart"><br><b>Polar chart</b><br><sub>Cyclic magnitude · linear radius</sub></td>
</tr> </tr>
</table> </table>
The v2.5.10 release added the final ten types above. Compare their light, dark, and full-editorial variants in the [30-variant contact sheet](.github/pr-previews/editorial-diagrams-2.5.10.jpg). The v2.5.10 release added the final ten types above. Compare their light, dark, and full-editorial variants in the [30-variant contact sheet](.github/pr-previews/editorial-diagrams-2.5.10.jpg).
**Browse the live gallery:** [cathrynlavery.github.io/diagram-design](https://cathrynlavery.github.io/diagram-design/) — or open [`skills/diagram-design/assets/index.html`](skills/diagram-design/assets/index.html) locally to flip through all 38 diagrams with light / dark / full-editorial tabs. **Browse the live gallery:** [cathrynlavery.github.io/diagram-design](https://cathrynlavery.github.io/diagram-design/) — or open [`skills/diagram-design/assets/index.html`](skills/diagram-design/assets/index.html) locally to flip through all 39 diagrams with light / dark / full-editorial tabs.
--- ---
@@ -145,7 +145,7 @@ Droid tracks Git plugins by commit rather than the manifest's display version. T
pi install https://github.com/cathrynlavery/diagram-design pi install https://github.com/cathrynlavery/diagram-design
``` ```
Run `/reload` in an open Pi session. Pi makes the skill available for matching diagram requests; use `/skill:diagram-design` to invoke it explicitly. Pi also loads the `/export-diagram`, `/import-mermaid`, and `/profile` prompt templates. The unpinned Git install is intentional: Pi has no automatic package refresh, so run `pi update --extensions` to pull merged updates. Run `/reload` in an open Pi session. Pi makes the skill available for matching diagram requests; use `/skill:diagram-design` to invoke it explicitly. Pi also loads the `/export-diagram`, `/import-mermaid`, `/profile`, and `/doctor` prompt templates. The unpinned Git install is intentional: Pi has no automatic package refresh, so run `pi update --extensions` to pull merged updates.
> **One-time migration:** an existing standalone `npx skills add` copy will not start following the Codex marketplace automatically. Remove that standalone copy, then use the Codex marketplace commands above. Likewise, uninstall a personal Cowork copy and reinstall Diagram Design from your organization's marketplace. Future marketplace version bumps then flow through each client's native update path. > **One-time migration:** an existing standalone `npx skills add` copy will not start following the Codex marketplace automatically. Remove that standalone copy, then use the Codex marketplace commands above. Likewise, uninstall a personal Cowork copy and reinstall Diagram Design from your organization's marketplace. Future marketplace version bumps then flow through each client's native update path.
@@ -213,7 +213,7 @@ Every diagram template gives the inline SVG an accessible name and description:
### Manual override ### Manual override
Prefer to set tokens by hand? Open [`skills/diagram-design/references/style-guide.md`](skills/diagram-design/references/style-guide.md) and edit the table. Everything downstream reads from there — all 38 diagrams, the annotation primitive, and the gallery all inherit semantic role names (`accent`, not `#eb6c36`). Prefer to set tokens by hand? Open [`skills/diagram-design/references/style-guide.md`](skills/diagram-design/references/style-guide.md) and edit the table. Everything downstream reads from there — all 39 diagrams, the annotation primitive, and the gallery all inherit semantic role names (`accent`, not `#eb6c36`).
### First-run gate ### First-run gate
@@ -234,7 +234,7 @@ The profile library is shared across Claude Code, Codex, Factory Droid, and Pi.
## Quickstart ## Quickstart
```bash ```bash
# From a cloned checkout, open the gallery to see all 38 diagrams # From a cloned checkout, open the gallery to see all 39 diagrams
open skills/diagram-design/assets/index.html # macOS open skills/diagram-design/assets/index.html # macOS
xdg-open skills/diagram-design/assets/index.html # Linux xdg-open skills/diagram-design/assets/index.html # Linux
@@ -357,11 +357,13 @@ diagram-design/
│ ├── export-diagram.md — plugin export command │ ├── export-diagram.md — plugin export command
│ ├── import-drawio.md — plugin draw.io import command │ ├── import-drawio.md — plugin draw.io import command
│ ├── import-mermaid.md — plugin Mermaid import command │ ├── import-mermaid.md — plugin Mermaid import command
│ └── profile.md — plugin client-profile command │ ├── profile.md — plugin client-profile command
│ └── doctor.md — plugin environment diagnostics command
├── prompts/ ├── prompts/
│ ├── export-diagram.md — Pi `/export-diagram` prompt template │ ├── export-diagram.md — Pi `/export-diagram` prompt template
│ ├── import-mermaid.md — Pi Mermaid import prompt template │ ├── import-mermaid.md — Pi Mermaid import prompt template
│ └── profile.md — Pi `/profile` prompt template │ ├── profile.md — Pi `/profile` prompt template
│ └── doctor.md — Pi `/doctor` diagnostics prompt template
├── skills/ ├── skills/
│ └── diagram-design/ │ └── diagram-design/
│ ├── SKILL.md — philosophy, selection guide, checklist │ ├── SKILL.md — philosophy, selection guide, checklist
@@ -409,7 +411,7 @@ diagram-design/
│ └── assets/ │ └── assets/
│ ├── index.html — live gallery, tabbed │ ├── index.html — live gallery, tabbed
│ ├── template*.html — scaffolds for new diagrams │ ├── template*.html — scaffolds for new diagrams
│ ├── example-<type>.html — 3 variants × 38 types │ ├── example-<type>.html — 3 variants × 39 types
│ ├── example-loop-terminal.html │ ├── example-loop-terminal.html
│ ├── example-quadrant-consultant.html │ ├── example-quadrant-consultant.html
│ ├── example-import-drawio.html │ ├── example-import-drawio.html
@@ -418,11 +420,18 @@ diagram-design/
│ └── example-sequence-oauth*.html │ └── example-sequence-oauth*.html
├── scripts/ ├── scripts/
│ ├── bump-plugin-version.py — synchronized Claude/Codex/Factory version bump │ ├── bump-plugin-version.py — synchronized Claude/Codex/Factory version bump
│ ├── render-canonical-screenshots.py — deterministic 38-type PNG catalog renderer │ ├── render-canonical-screenshots.py — deterministic 39-type PNG catalog renderer
│ ├── verify-screenshot-freshness.py — source + screenshot digest gate │ ├── verify-screenshot-freshness.py — source + screenshot digest gate
│ ├── verify-plugin-package.py — version + marketplace package gate │ ├── verify-plugin-package.py — version + marketplace package gate
│ ├── test-plugin-package.py — adversarial package-gate tests │ ├── test-plugin-package.py — adversarial package-gate tests
│ ├── test-verify-docs-sync.py — docs/profile-surface gate tests │ ├── lint-render.py — Chromium rendered-layout checker
│ ├── verify-doctor.py — doctor diagnostics contract gate
│ ├── test-verify-doctor.py — doctor diagnostics adversarial tests
│ ├── verify-polar.py — quantitative polar encoding gate
│ ├── test-verify-polar.py — polar gate adversarial tests
│ ├── verify-sankey.py — Sankey conservation + geometry gate
│ ├── test-verify-sankey.py — Sankey gate adversarial tests
│ ├── test-verify-docs-sync.py — docs/routing-surface gate tests
│ └── fixtures/ │ └── fixtures/
│ ├── sample-flowchart.mmd │ ├── sample-flowchart.mmd
│ ├── sample-readme-with-mermaid.md │ ├── sample-readme-with-mermaid.md
@@ -449,10 +458,47 @@ behavior, resource caps, named failures, and reference/command wiring.
Label placement is gated geometrically: `python3 scripts/verify-geometry.py --all` fails CI when a label mask overlaps a node declared later in the document, because the node fill would clip the text at render time. `python3 scripts/test-verify-geometry.py` keeps that checker honest in both directions. Label placement is gated geometrically: `python3 scripts/verify-geometry.py --all` fails CI when a label mask overlaps a node declared later in the document, because the node fill would clip the text at render time. `python3 scripts/test-verify-geometry.py` keeps that checker honest in both directions.
Treemaps get a second geometric gate, because their whole claim is that area *is* the encoding: `python3 scripts/verify-treemap.py --all` fails CI when a cell's share of the drawn area doesn't match the value printed inside it, or when a label overruns the cell it names. It measures area error as a *relative* figure — an absolute one passes exactly the small cells most likely to be wrong. `python3 scripts/test-verify-treemap.py` keeps it honest in both directions. Treemaps get a second geometric gate, because their whole claim is that area *is* the encoding: `python3 scripts/verify-treemap.py --all` fails CI when a cell's share of the drawn area doesn't match the value printed inside it, or when a label overruns the cell it names. It measures area error as a *relative* figure — an absolute one passes exactly the small cells most likely to be wrong. `python3 scripts/test-verify-treemap.py` keeps it honest in both directions.
Docs and routing surfaces are themselves gated: `python3 scripts/verify-docs-sync.py` fails CI if the SKILL.md description loses a type's lexical hook, the gallery can't reach a shipped example, the README tree names a file that doesn't exist, a relative reference link is broken, a scanner-visible support path is not shipped inside the skill package, or the Claude/Pi profile surfaces drift from `profiles.md`. `python3 scripts/test-verify-docs-sync.py` exercises those newer checks adversarially, including the strict-bundler behavior used by Hermes Agent. The skill also ships `skills/diagram-design/scripts/self_check.py` — a distilled output checker installed agents can run on their own generated diagrams; `python3 scripts/test-self-check.py` keeps it honest. Settled design decisions (why one pinned controller, why patterns never add types, the autoplay policy, the SKILL.md byte cap, why label placement is verified geometrically, and why client profiles use marker-first resolution) live as short ADRs in `docs/adr/` — read them before relitigating one, add one when you settle a new policy. Docs and routing surfaces are themselves gated: `python3 scripts/verify-docs-sync.py` fails CI if the SKILL.md description loses a type's lexical hook, the gallery can't reach a shipped example, the README tree names a file that doesn't exist, a relative reference link is broken, a scanner-visible support path is not shipped inside the skill package, or any command/prompt surface drifts from its routed reference. `python3 scripts/test-verify-docs-sync.py` exercises those newer checks adversarially, including the strict-bundler behavior used by Hermes Agent. The skill also ships `skills/diagram-design/scripts/self_check.py` — a distilled output checker installed agents can run on their own generated diagrams; `python3 scripts/test-self-check.py` keeps it honest. Settled design decisions (why one pinned controller, why patterns never add types, the autoplay policy, the SKILL.md byte cap, why label placement is verified geometrically, and why client profiles use marker-first resolution) live as short ADRs in `docs/adr/` — read them before relitigating one, add one when you settle a new policy.
All pull requests and pushes are automatically validated across Linux, Windows, and macOS runners via GitHub Actions CI (`.github/workflows/ci.yml`). All pull requests and pushes are automatically validated across Linux, Windows, and macOS runners via GitHub Actions CI (`.github/workflows/ci.yml`).
`lint-skin.py` reads the source. `lint-render.py` renders it — headless Chromium
reports what actually got painted, which catches content cut off by the SVG
viewport, collapsed SVGs, horizontal page overflow, missing local assets and JS
errors. Both run in CI on every pull request.
```bash
pip install playwright && playwright install chromium # same dep as PNG export
python3 scripts/lint-render.py --self-test # checks the checks
python3 scripts/lint-render.py --all # examples and templates
python3 scripts/lint-render.py <your-new-example.html>
python3 scripts/lint-render.py --fonts --all # measure with the real webfonts
```
Clipping is measured by paint, not geometry: `getBoundingClientRect()` on an SVG
child ignores stroke width, markers and filter bleed, and knows nothing about
`clip-path` or `overflow: visible`, so it both misses real clipping and invents
clipping that isn't there. Instead each SVG is screenshot as authored and again
with its `overflow` released, and the two are diffed — ink that appears outside
was being cut off. Releases are staged — the SVG alone, then each clipping
ancestor — so a wrapper release can't mask spill at the SVG's own edge, and an SVG
authored `overflow: visible` inside a clipping wrapper is still checked.
`--self-test` asserts all of that on 23 cases, over half of them cases that must
*not* be flagged, and it also asserts the DOM is byte-identical after measuring.
No golden images, so there is nothing to re-record and no PNGs in the repo.
Network is cut at the browser's resolver, which covers WebSockets and anything
else that bypasses request routing, with request routing as a second layer;
`--fonts` excludes exactly the two Google Fonts hostnames and allows them only
over HTTPS. Since the oracle is pixels, CI pins Playwright and its Chromium build
rather than installing whatever is newest.
**Font metrics differ between the default run and `--fonts`.** With network
blocked — the default, and what CI runs — text is laid out in the fallback faces,
not Instrument Serif and Geist. That is deterministic and machine-independent,
which is what a linter needs, but it is not what your reader sees. Run
`--fonts --all` locally when you care whether real text fits its box.
### 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`; semantic, type, and animation references are pulled in only when relevant. 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.
@@ -470,7 +516,7 @@ At startup, the agent sees only the skill name and description. When a request m
| "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 static diagram-making (any of the 38 visual types) | Only `SKILL.md` + that one type's reference | | Routine static diagram-making (any of the 39 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.
+22
View File
@@ -0,0 +1,22 @@
---
description: Run one-shot environment diagnostics for Diagram Design readiness
argument-hint: [--strict] [--json]
allowed-tools:
- Read
- Bash
- Glob
---
Run environment diagnostics for Diagram Design by following [`skills/diagram-design/references/doctor.md`](../skills/diagram-design/references/doctor.md). Treat that reference as the source of truth and do not reimplement its logic here.
Full argument string: `$ARGUMENTS`
## Required behavior
1. Locate and apply the exact checks and output contract from `references/doctor.md`.
2. Keep this command read-only: do not install dependencies, do not edit files, and do not run destructive git operations.
3. If a check command fails, capture stderr, mark the check `warn` or `fail` per the reference, and continue remaining checks.
4. Print the compact summary line plus per-check statuses, and include `Next actions` only when needed.
5. If `--json` is passed, append the structured JSON block defined by the reference.
Report only verified results from this run.
+2 -1
View File
@@ -22,7 +22,8 @@ Accepts `.drawio`, `.drawio.xml`, `.xml`, `.drawio.png`, and `.drawio.svg`.
- `--detail=balanced` · `--audience=mixed`. - `--detail=balanced` · `--audience=mixed`.
- `--variant=light` — the minimal light template. - `--variant=light` — the minimal light template.
- A single-page file selects its only page; a multi-page file lists pages and asks which to use. - A single-page file selects its only page; a multi-page file lists pages and asks which to use.
- Type is chosen from the extracted structure; `--type` forces one of the 38. - Type is chosen from the extracted structure; `--type` forces one of the visual types in
[`SKILL.md` §3](../skills/diagram-design/SKILL.md).
## Flags ## Flags
+2 -1
View File
@@ -22,7 +22,8 @@ Accepts `.mmd`, `.mermaid`, and Markdown files containing fenced `mermaid` block
- `--detail=balanced` · `--audience=mixed`. - `--detail=balanced` · `--audience=mixed`.
- `--variant=light` — the minimal light template. - `--variant=light` — the minimal light template.
- A single diagram selects diagram 0; a multi-block Markdown file lists blocks and asks which to use. - A single diagram selects diagram 0; a multi-block Markdown file lists blocks and asks which to use.
- Type is chosen from the extracted grammar and structure; `--type` forces one of the 38. - Type is chosen from the extracted grammar and structure; `--type` forces one of the visual
types in [`SKILL.md` §3](../skills/diagram-design/SKILL.md).
## Flags ## Flags
+1 -1
View File
@@ -1,6 +1,6 @@
--- ---
description: Save, load, inspect, update, reset, or delete diagram-design client profiles description: Save, load, inspect, update, reset, or delete diagram-design client profiles
argument-hint: [list|save|load|show|update|reset|delete] [name] argument-hint: "[list|save|load|show|update|reset|delete] [name]"
allowed-tools: allowed-tools:
- Read - Read
- Write - Write
@@ -1,6 +1,6 @@
# ADR 0002 — Semantic patterns never expand the visual-type taxonomy # ADR 0002 — Semantic patterns never expand the visual-type taxonomy
**Status:** accepted (v2.3) **Status:** accepted (v2.3; amended v2.6)
## Context ## Context
@@ -8,7 +8,7 @@ Auditing behavior-rich figures (queues, policy traces, trust boundaries) showed
## Decision ## Decision
Behavior is a separate axis. The seven semantic patterns in `references/semantic-patterns.md` each route to the **nearest existing visual type** for layout; a pattern owns semantic primitives and a tighter budget, never a second layout grammar. The visual-type count only moves when a genuinely new *layout* grammar appears (it did in v2.5.10 — see [ADR 0007](0007-new-layout-grammars.md), 28 → 38). Behavior is a separate axis. The seven semantic patterns in `references/semantic-patterns.md` each route to the **nearest existing visual type** for layout; a pattern owns semantic primitives and a tighter budget, never a second layout grammar. The visual-type count only moves when a genuinely new *layout* grammar appears (it did in v2.5.10 — see [ADR 0007](0007-new-layout-grammars.md), 28 → 38 — and again for Polar, 38 → 39).
## Consequences ## Consequences
@@ -20,6 +20,8 @@ Behavior is a separate axis. The seven semantic patterns in `references/semantic
**2026-08-18 — the count is 28.** Treemap was admitted under the escape clause above: recursive area subdivision is a layout grammar no existing type provides (bar encodes with length, nested with containment and no quantity, pyramid with rank). It shipped the full §10 set, and the counters named above moved 27 → 28 together with the prose. **2026-08-18 — the count is 28.** Treemap was admitted under the escape clause above: recursive area subdivision is a layout grammar no existing type provides (bar encodes with length, nested with containment and no quantity, pyramid with rank). It shipped the full §10 set, and the counters named above moved 27 → 28 together with the prose.
The decision itself is unchanged — semantic patterns still never add a type, and the count still moves only for a new *layout* grammar. What this amendment records is the procedure: the two counters are this ADR's enforcement, so a PR that edits them without amending this file has quietly made itself the authority. Amend here in the same PR, or the number in the test is just whatever the last contributor typed.
**2026-08-19 — the count is 38.** Ten additional visual grammars were admitted under the same escape clause: Sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, and database schema. The per-type argument is in [ADR 0007](0007-new-layout-grammars.md); each ships the full §10 set, and the two counters move 28 → 38 together with the prose. **2026-08-19 — the count is 38.** Ten additional visual grammars were admitted under the same escape clause: Sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, and database schema. The per-type argument is in [ADR 0007](0007-new-layout-grammars.md); each ships the full §10 set, and the two counters move 28 → 38 together with the prose.
**2026-08-20 — the count is 39.** Polar was admitted under the same escape clause: angle encodes ordered cyclic categories and linear radius encodes one quantitative series, a layout grammar no existing type provides. It shipped the full §10 set, and the counters named above moved 38 → 39 together with the prose.
The decision itself is unchanged — semantic patterns still never add a type, and the count still moves only for a new *layout* grammar. What this amendment records is the procedure: the two counters are this ADR's enforcement, so a PR that edits them without amending this file has quietly made itself the authority. Amend here in the same PR, or the number in the test is just whatever the last contributor typed.
+5 -1
View File
@@ -61,4 +61,8 @@ Kept out on purpose, so the next audit does not relitigate them.
- **ER's scope was narrowed, not duplicated.** `type-er.md` now states that it is entity-level and points at `type-db-schema.md` for the physical schema. Without that edit the two types would have overlapping "Best for" claims and the router would be ambiguous. - **ER's scope was narrowed, not duplicated.** `type-er.md` now states that it is entity-level and points at `type-db-schema.md` for the physical schema. Without that edit the two types would have overlapping "Best for" claims and the router would be ambiguous.
- The ADR 0004 byte cap became binding. Adding these types required trimming SKILL.md body prose: the §11 import consequences were compressed, the terminal-variant and typography paragraphs were tightened, and §4's six connector anti-pattern rows collapsed into one row pointing at §6 — that table was the third statement of rules already given in full in §6 and checked one by one in §9. `SKILL.md` sits at ~39.2 KB against the 40 KB cap. The next type must be paid for the same way, never by trimming the frontmatter description. - The ADR 0004 byte cap became binding. Adding these types required trimming SKILL.md body prose: the §11 import consequences were compressed, the terminal-variant and typography paragraphs were tightened, and §4's six connector anti-pattern rows collapsed into one row pointing at §6 — that table was the third statement of rules already given in full in §6 and checked one by one in §9. `SKILL.md` sits at ~39.2 KB against the 40 KB cap. The next type must be paid for the same way, never by trimming the frontmatter description.
- Per-type budget rows stayed in SKILL.md §7 rather than moving into the type references, because several older references (venn, pyramid, layers, ER, swimlane, timeline) do not state their own limits. Moving the rows out would have lost those numbers. - Per-type budget rows stayed in SKILL.md §7 rather than moving into the type references, because several older references (venn, pyramid, layers, ER, swimlane, timeline) do not state their own limits. Moving the rows out would have lost those numbers.
- `docs/screenshots/` has no PNGs for the new types yet; the README lists them in a text table instead of the image grid until the screenshots are generated. - At acceptance time, `docs/screenshots/` had no PNGs for the new types; README listed them in a text table pending generation.
## Amendment — canonical screenshots shipped
The ten types now have canonical PNGs, image-grid entries in README, and source/screenshot digests in `docs/screenshots/manifest.json`. Polar was admitted separately afterward and moved the repository-wide count from 38 to 39; this ADR still records the ten-type 28 → 38 decision.
+9
View File
@@ -101,6 +101,15 @@
"width": 2400, "width": 2400,
"height": 1202 "height": 1202
}, },
{
"slug": "polar",
"source": "skills/diagram-design/assets/example-polar.html",
"screenshot": "docs/screenshots/polar.png",
"source_sha256": "596e2042f18044ef1fa2c2eb849f39fa5c7767b2a17e272617ebd27e10bc86b1",
"screenshot_sha256": "a1dd1d4cb38db2217759c5d256cc56d832acc4f3077f090cc8691d6e4f82ef31",
"width": 2400,
"height": 1202
},
{ {
"slug": "loop", "slug": "loop",
"source": "skills/diagram-design/assets/example-loop.html", "source": "skills/diagram-design/assets/example-loop.html",
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

@@ -0,0 +1,592 @@
# Quantitative Polar Chart Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a verified 28th visual type that renders one cyclic quantitative series as radial lollipops with angle=category and linear radius=magnitude.
**Architecture:** The public contract lives in `references/type-polar.md`; three static HTML examples carry machine-readable `data-polar-*` metadata. A dependency-free Python verifier recomputes all endpoints and rejects hubs, nonlinear radius, visible zero rays, invalid values, and variant drift. Existing routing, gallery, README, CI, and package manifests are updated as one minor release.
**Tech Stack:** Python standard library 3.10+, static HTML/CSS, inline SVG, existing repository validation scripts, headless Google Chrome for the README screenshot.
**Spec:** `docs/superpowers/specs/2026-08-18-polar-chart-design.md`
## Global Constraints
- Angle encodes category; radius encodes magnitude with `radius = R * value / max`.
- The shared scale starts at exactly zero; `max` is finite and greater than zero.
- Zero renders with no value ray and no endpoint marker; its numeric label remains visible.
- One series, 4–8 unique ordered categories, and at most one focal category.
- No filled quantitative wedges, donut hub, gradients, negative values, logarithmic scale, interaction, animation, or external assets.
- All three examples use the same values, `viewBox="0 0 1000 520"`, center `(500,230)`, radius `160`, max `100`, start angle `-90`, and clockwise order.
- Static examples must satisfy the accessible SVG contract and existing skin rules.
- No new runtime or test dependency.
- Add a minor plugin release: `2.4.4 → 2.5.0` unless `origin/main` advances first; after a rebase, rerun `python3 scripts/bump-plugin-version.py --minor` from the new base.
---
### Task 1: Build the quantitative polar verifier with adversarial tests
**Files:**
- Create: `scripts/test-verify-polar.py`
- Create: `scripts/verify-polar.py`
**Interfaces:**
- Produces: `check(path: Path) -> list[str]`, `check_variants(paths: Sequence[Path]) -> list[str]`, and CLI `python3 scripts/verify-polar.py [file ...]`.
- Consumes later: Task 2 uses the verifier as the RED/GREEN gate for the three examples; Task 4 adds both commands to CI.
- [ ] **Step 1: Write the failing verifier tests**
Create `scripts/test-verify-polar.py` with a fixture builder for a four-category chart. The valid fixture has center `(100,100)`, `R=100`, max `100`, angles `-90/0/90/180`, and values `0/25/50/100`:
```python
#!/usr/bin/env python3
from __future__ import annotations
import importlib.util
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VERIFIER = ROOT / "scripts/verify-polar.py"
def load_verifier():
spec = importlib.util.spec_from_file_location("verify_polar", VERIFIER)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def category(index: int, value: int, endpoint: tuple[float, float] | None) -> str:
body = '<text data-polar-value-label="">{}</text>'.format(value)
if endpoint is not None:
x, y = endpoint
body = (
f'<line data-polar-ray="" x1="100" y1="100" x2="{x}" y2="{y}"/>'
f'<circle data-polar-marker="" cx="{x}" cy="{y}" r="4"/>' + body
)
return (
f'<g data-polar-category="c{index}" data-polar-index="{index}" '
f'data-polar-value="{value}">{body}</g>'
)
def document(categories: str, extra_svg: str = "") -> str:
return (
'<!DOCTYPE html><html><body>'
'<svg data-polar-chart="" data-polar-cx="100" data-polar-cy="100" '
'data-polar-radius="100" data-polar-min="0" data-polar-max="100" '
'data-polar-start-angle="-90" data-polar-clockwise="true" '
'data-polar-radius-encoding="linear" data-polar-inner-radius="0">'
f'{extra_svg}{categories}</svg></body></html>'
)
VALID = document(
category(0, 0, None)
+ category(1, 25, (125, 100))
+ category(2, 50, (100, 150))
+ category(3, 100, (0, 100))
)
```
In `main()`, write each fixture to a temporary file and assert these results:
```python
cases = [
("25 percent hub", VALID.replace('data-polar-inner-radius="0"', 'data-polar-inner-radius="25"'), "inner radius"),
("sqrt radius", VALID.replace('x2="125"', 'x2="150"').replace('cx="125"', 'cx="150"'), "linear endpoint"),
("visible zero ray", VALID.replace('<text data-polar-value-label="">0</text>', '<line data-polar-ray="" x1="100" y1="100" x2="100" y2="100"/><text data-polar-value-label="">0</text>'), "zero category"),
("out of range", VALID.replace('data-polar-value="100"', 'data-polar-value="101"'), "outside 0..100"),
("duplicate index", VALID.replace('data-polar-index="3"', 'data-polar-index="2"'), "indices"),
("quantitative wedge", VALID.replace('</svg>', '<path data-polar-wedge="" d="M0 0"/></svg>'), "wedge"),
]
```
Assert `check(valid_path) == []`. For every invalid case, assert at least one finding exists and one finding contains the named substring; do not assert the total count because a malformed value may correctly violate more than one invariant.
Also create three valid files and change the dark fixture's `data-polar-value="25"` to `26`; assert `check_variants()` reports `variant data drift`.
- [ ] **Step 2: Run the test to verify RED**
Run:
```bash
python3 scripts/test-verify-polar.py
```
Expected: fail while importing missing `scripts/verify-polar.py`.
- [ ] **Step 3: Implement the minimal verifier**
Create `scripts/verify-polar.py` using only `dataclasses`, `html.parser`, `math`, `pathlib`, `sys`, and `typing`.
Define these data structures and parser output:
```python
@dataclass
class Category:
name: str
index: int
value: float
focal: bool
rays: list[dict[str, str]]
markers: list[dict[str, str]]
value_labels: int
@dataclass
class Chart:
attrs: dict[str, str]
categories: list[Category]
wedge_count: int
```
`PolarParser(HTMLParser)` must capture exactly one element carrying `data-polar-chart`; capture `g[data-polar-category]`, its `line[data-polar-ray]`, `circle[data-polar-marker]`, and `text[data-polar-value-label]`; count any `path[data-polar-wedge]`.
Implement `check()` with these exact invariants:
```python
TOLERANCE = 0.75
required = (
"data-polar-cx", "data-polar-cy", "data-polar-radius",
"data-polar-min", "data-polar-max", "data-polar-start-angle",
"data-polar-clockwise", "data-polar-radius-encoding",
"data-polar-inner-radius",
)
expected_angle = radians(start_angle) + (1 if clockwise else -1) * 2 * pi * index / count
expected_radius = radius * value / maximum
expected_x = cx + expected_radius * cos(expected_angle)
expected_y = cy + expected_radius * sin(expected_angle)
```
Return findings for: missing/duplicate chart; missing or non-numeric metadata; min not zero; max/radius not positive; non-linear encoding; non-zero inner radius; category count outside 4–8; non-contiguous indices; duplicate trimmed names; more than one focal; value outside range; value-label count not one; zero carrying a ray or marker; positive value not carrying exactly one ray and marker; ray start not at center; ray/marker endpoint outside tolerance; any wedge.
Implement `check_variants()` by comparing this signature for all paths:
```python
signature = (
cx, cy, radius, minimum, maximum, start_angle, clockwise,
tuple((c.index, c.name, c.value, c.focal) for c in sorted(categories, key=lambda c: c.index)),
)
```
With no CLI arguments, validate the exact shipped paths `example-polar.html`, `example-polar-dark.html`, and `example-polar-full.html`; with arguments, validate those files. Print each finding as `FAIL <path>: <message>` and return 1 if any finding exists; otherwise print `OK <path>` and return 0.
- [ ] **Step 4: Run GREEN and confirm the shipped-file gate is still RED**
Run:
```bash
python3 scripts/test-verify-polar.py
python3 scripts/verify-polar.py
```
Expected: adversarial tests pass; shipped verifier fails because the three example files do not exist yet.
- [ ] **Step 5: Commit the verifier slice**
```bash
git add scripts/test-verify-polar.py scripts/verify-polar.py
git commit -m "test(polar): verify linear radius encoding"
```
---
### Task 2: Add the type reference and three verified static examples
**Files:**
- Create: `skills/diagram-design/references/type-polar.md`
- Create: `skills/diagram-design/assets/example-polar.html`
- Create: `skills/diagram-design/assets/example-polar-dark.html`
- Create: `skills/diagram-design/assets/example-polar-full.html`
**Interfaces:**
- Consumes: `scripts/verify-polar.py` and the approved spec.
- Produces: three identical `data-polar-*` datasets consumed by Task 3 gallery/docs wiring.
- [ ] **Step 1: Preserve the missing-example RED result**
Run:
```bash
python3 scripts/verify-polar.py
```
Expected: fail naming all three missing example files.
- [ ] **Step 2: Write `type-polar.md` from the approved contract**
Create these sections in order:
```markdown
# Polar Chart
**Best for:** one quantitative series across 4–8 categories whose clockwise order is meaningful.
## Input contract
## Quantitative encoding
### Zero
## Layout conventions
## Geometry
## Visual treatment
## Complexity budget
## When not to use
## Anti-patterns
## Examples
```
Copy the formulas, zero behavior, exclusions, scale validation, label-anchor rules, and `1000×520` reference geometry verbatim from the spec. The anti-pattern list must explicitly reject donut hubs, filled wedges, tangent labels, multiple series, truncated scales, sorted-by-value categories, and treating missing as zero. Route multiple series to Radar, non-cyclic categories to Bar, and more than eight time buckets to Line.
- [ ] **Step 3: Build the minimal-light example with exact geometry**
Start from `skills/diagram-design/assets/template.html`. Use this dataset and endpoint table; retain three decimals where shown so the verifier tolerance is meaningful:
| i | label | value | spoke end | value end | label point (`R+28`) |
|---|---|---:|---|---|---|
| 0 | 00–03 | 32 | `500,70` | `500,178.8` | `500,42` |
| 1 | 03–06 | 18 | `613.137,116.863` | `520.365,209.635` | `632.936,97.064` |
| 2 | 06–09 | 24 | `660,230` | `538.4,230` | `688,230` |
| 3 | 09–12 | 58 | `613.137,343.137` | `565.62,295.62` | `632.936,362.936` |
| 4 | 12–15 | 100 focal | `500,390` | `500,390` | `500,418` |
| 5 | 15–18 | 82 | `386.863,343.137` | `407.227,322.773` | `367.064,362.936` |
| 6 | 18–21 | 76 | `340,230` | `378.4,230` | `312,230` |
| 7 | 21–24 | 45 | `386.863,116.863` | `449.088,179.088` | `367.064,97.064` |
The primary SVG opening must be:
```html
<svg viewBox="0 0 1000 520" xmlns="http://www.w3.org/2000/svg"
role="img" aria-labelledby="polar-title polar-desc"
data-polar-chart data-polar-cx="500" data-polar-cy="230"
data-polar-radius="160" data-polar-min="0" data-polar-max="100"
data-polar-start-angle="-90" data-polar-clockwise="true"
data-polar-radius-encoding="linear" data-polar-inner-radius="0">
```
Use `<circle>` grid rings at `r=32/64/96/128/160`, eight faint full-radius spokes, then one group per category:
```html
<g data-polar-category="00–03" data-polar-index="0" data-polar-value="32">
<line data-polar-ray x1="500" y1="230" x2="500" y2="178.8"
stroke="#4f5d75" stroke-width="2"/>
<circle data-polar-marker cx="500" cy="178.8" r="4"
fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.2"/>
<text data-polar-value-label x="500" y="26" ...>32</text>
</g>
```
Set `data-polar-focal="true"` only on `12–15`; use accent stroke and a 5 px endpoint marker there. Every other group uses muted. Put the scale values `20/40/60/80/100` on the top axis and a single `0` beside the center. Add `Illustrative normalized workload profile` below a hairline at the bottom. The `<desc>` must identify `12–15 UTC` as the 100% peak and state that the other seven windows proceed clockwise.
- [ ] **Step 4: Create dark and full variants without dataset drift**
Create `example-polar-dark.html` from `template-dark.html` and `example-polar-full.html` from `template-full.html`. Keep every `data-polar-*` attribute and all chart coordinates byte-equivalent to the light variant; only skin tokens and surrounding editorial cards differ.
The full variant has three unequal cards:
- `Peak · 12–15 UTC · 100%`
- `Quietest · 03–06 UTC · 18%`
- `Evening tail · 18–21 UTC · 76%`
Use prefixed accessible IDs `polar-dark-title/desc` and `polar-full-title/desc`.
- [ ] **Step 5: Run quantitative, skin, accessibility, and visual gates**
Run:
```bash
python3 scripts/test-verify-polar.py
python3 scripts/verify-polar.py
python3 scripts/lint-skin.py \
skills/diagram-design/assets/example-polar.html \
skills/diagram-design/assets/example-polar-dark.html \
skills/diagram-design/assets/example-polar-full.html
python3 scripts/verify-geometry.py \
skills/diagram-design/assets/example-polar.html \
skills/diagram-design/assets/example-polar-dark.html \
skills/diagram-design/assets/example-polar-full.html
```
Expected: all commands exit 0 and report zero findings.
Open each HTML file in a fresh browser tab. Verify: the focal ray is the only accent; rings do not read as filled wedges; labels are upright and unclipped; `03–06` visibly lands at 18%; the full card layout does not change SVG geometry.
- [ ] **Step 6: Commit the reference and examples**
```bash
git add \
skills/diagram-design/references/type-polar.md \
skills/diagram-design/assets/example-polar.html \
skills/diagram-design/assets/example-polar-dark.html \
skills/diagram-design/assets/example-polar-full.html
git commit -m "feat(types): add quantitative polar chart"
```
---
### Task 3: Wire the 28th type through routing, gallery, documentation, and count gates
**Files:**
- Modify: `skills/diagram-design/SKILL.md:1-13,52,81-112,356-385`
- Modify: `skills/diagram-design/assets/index.html:197-309`
- Modify: `README.md:15-88,188,209,373,431`
- Create: `docs/screenshots/polar.png`
- Modify: `commands/import-drawio.md:25`
- Modify: `commands/import-mermaid.md:25`
- Modify: `skills/diagram-design/references/onboarding.md:162`
- Modify: `skills/diagram-design/references/semantic-patterns.md:3`
- Modify: `docs/adr/0002-semantic-patterns-do-not-expand-the-taxonomy.md:1-17`
- Modify: `CONTRIBUTING.md:11,130`
- Modify: `scripts/verify-docs-sync.py:22-79`
- Modify: `scripts/verify-semantic-motion.py:190-207,422`
- Modify: `scripts/test-verify-motion.py:401-415`
**Interfaces:**
- Consumes: `type-polar.md` and the three examples from Task 2.
- Produces: discoverable routing vocabulary, 28-row taxonomy, gallery reachability, and count assertions consumed by Task 4 CI.
- [ ] **Step 1: Raise the count gates first and verify RED**
In `scripts/verify-docs-sync.py`, add `VISUAL_TYPE_COUNT = 28` beside `VARIANTS` and replace both hardcoded `27` values in `check_description()` with the constant.
In `scripts/verify-semantic-motion.py`, add `VISUAL_TYPE_COUNT = 28`, derive `guide_heading = f"### Visual-type guide ({VISUAL_TYPE_COUNT})"`, require 28 rows, and update the success message. In `scripts/test-verify-motion.py`, replace the missing-guide fixture and expected diagnostic with `28`.
Run:
```bash
python3 scripts/verify-docs-sync.py
python3 scripts/verify-semantic-motion.py --markdown-only
```
Expected: both fail because SKILL.md still has 27 rows and the old heading.
- [ ] **Step 2: Add Polar to SKILL.md and the complexity budget**
Make these exact routing changes:
- Add `polar/radial lollipop` to the frontmatter description.
- Change `metadata.version` from `2.4` to `2.5`.
- Change `Twenty-seven` and both numeric `27` references to `Twenty-eight`/`28`.
- Rename the heading to `### Visual-type guide (28)`.
- Add this row immediately after Radar:
```markdown
| One quantitative series across cyclic categories; angle=category, radius=magnitude | **Polar chart** | [type-polar.md](references/type-polar.md) |
```
- Add complexity rows `Max polar categories | 8`, `Max polar series | 1`, and `Max focal polar categories | 1` after the radar rows.
Run `python3 scripts/verify-docs-sync.py`; expect the type-count and description/reference checks to be repaired, with the command still failing only because the new examples are not gallery-reachable yet.
- [ ] **Step 3: Register all three examples in the gallery**
Insert a non-`data-single` tab immediately after Radar:
```html
<button class="tab new" data-type="polar">
<span class="eyebrow">16</span>Polar
</button>
```
Increment every later numeric eyebrow so gallery numbers remain unique and ascending. Do not change `data-type`, `data-single`, variant behavior, or keyboard navigation.
Run:
```bash
python3 scripts/verify-docs-sync.py
```
Expected: gallery reachability passes for `example-polar{,-dark,-full}.html`.
- [ ] **Step 4: Update every current count surface while preserving historical text**
Change active product claims from 27 to 28 in README, CONTRIBUTING, both import commands, onboarding, semantic-patterns, and ADR 0002. In ADR 0002, explain that Polar is the first admitted new grammar and the invariant is now 28. Do not alter ADR 0004's historical statement that v2.3 once carried 27 type names.
Add this README gallery cell in the final row:
```html
<td align="center"><img src="docs/screenshots/polar.png" alt="Polar chart"><br><b>Polar chart</b><br><sub>Cyclic magnitude · linear radius</sub></td>
```
Keep the third cell empty unless another type lands during rebase.
- [ ] **Step 5: Generate and inspect the README screenshot**
Run from the repository root:
```bash
chrome='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
page="file://$(pwd)/skills/diagram-design/assets/example-polar.html"
"$chrome" --headless=new --hide-scrollbars --force-device-scale-factor=2 \
--window-size=1200,601 --screenshot=docs/screenshots/polar.png "$page"
sips -g pixelWidth -g pixelHeight docs/screenshots/polar.png
```
Expected dimensions: `2400 × 1202`. Inspect the PNG and confirm the entire chart, title, labels, and source note are visible with no browser chrome.
- [ ] **Step 6: Run routing/count tests and the SKILL.md byte cap**
```bash
python3 scripts/verify-docs-sync.py
python3 scripts/test-verify-docs-sync.py
python3 scripts/verify-semantic-motion.py --markdown-only
python3 scripts/test-verify-motion.py
```
Expected: all pass; semantic verification reports 28 visual types and SKILL.md remains below 40,000 bytes.
- [ ] **Step 7: Commit the routing and documentation slice**
```bash
git add \
skills/diagram-design/SKILL.md \
skills/diagram-design/assets/index.html \
README.md docs/screenshots/polar.png \
commands/import-drawio.md commands/import-mermaid.md \
skills/diagram-design/references/onboarding.md \
skills/diagram-design/references/semantic-patterns.md \
docs/adr/0002-semantic-patterns-do-not-expand-the-taxonomy.md \
CONTRIBUTING.md \
scripts/verify-docs-sync.py scripts/verify-semantic-motion.py \
scripts/test-verify-motion.py
git commit -m "docs(polar): register the 28th visual type"
```
---
### Task 4: Add CI coverage and publish the minor package version
**Files:**
- Modify: `.github/workflows/ci.yml:140-222`
- Modify: `CONTRIBUTING.md:40-82`
- Modify: `.claude-plugin/plugin.json`
- Modify: `.codex-plugin/plugin.json`
**Interfaces:**
- Consumes: verifier/tests from Task 1 and examples from Task 2.
- Produces: cross-platform CI enforcement and synchronized release version `2.5.0`.
- [ ] **Step 1: Add the polar gate to CI**
Insert after the skin linter:
```yaml
- name: Verify quantitative polar chart
if: always()
id: polar
shell: bash
run: |
python scripts/test-verify-polar.py
python scripts/verify-polar.py
```
Add this row to the execution summary after Color Palette & Skin Linter:
```yaml
echo "| Quantitative Polar Chart | ${{ steps.polar.outcome == 'success' && '✅ Passed' || (steps.polar.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
```
- [ ] **Step 2: Add the local gate to CONTRIBUTING**
Add table row:
```markdown
| Quantitative polar encoding and variant parity | `python3 scripts/test-verify-polar.py && python3 scripts/verify-polar.py` |
```
Add both commands after `lint-skin.py --all --baseline` in the combined validation command.
- [ ] **Step 3: Bump both package manifests together**
Run:
```bash
python3 scripts/bump-plugin-version.py --minor
jq -r '.version' .claude-plugin/plugin.json .codex-plugin/plugin.json
```
Expected: both print `2.5.0`. If main advanced, the expected value is the next minor derived from the rebased synchronized version.
- [ ] **Step 4: Verify package and CI YAML**
```bash
python3 scripts/test-plugin-package.py
python3 scripts/verify-plugin-package.py origin/main
claude plugin validate . --strict
python3 -c 'import yaml, pathlib; yaml.safe_load(pathlib.Path(".github/workflows/ci.yml").read_text())' 2>/dev/null \
|| ruby -e 'require "yaml"; YAML.load_file(".github/workflows/ci.yml"); puts "CI YAML OK"'
```
Expected: package checks pass, both versions are synchronized and increasing, plugin validation passes, and one available YAML parser accepts the workflow.
- [ ] **Step 5: Commit CI and release metadata**
```bash
git add \
.github/workflows/ci.yml CONTRIBUTING.md \
.claude-plugin/plugin.json .codex-plugin/plugin.json
git commit -m "ci(polar): enforce quantitative chart contract"
```
---
### Task 5: Run the complete release gate on the exact committed tree
**Files:**
- Verify only; modify only files named by a failing, reproducible gate.
**Interfaces:**
- Consumes: every artifact from Tasks 1–4.
- Produces: evidence that the exact branch tip is PR-ready.
- [ ] **Step 1: Confirm scope and branch provenance**
```bash
git status --short --branch
git diff --stat origin/main...HEAD
git log --oneline --decorate origin/main..HEAD
```
Expected: branch `feat/polar-chart`; no uncommitted files; only spec, plan, polar feature, count/docs, CI, screenshot, and version files.
- [ ] **Step 2: Run the complete CONTRIBUTING suite plus the new gate**
```bash
python3 scripts/test-plugin-package.py \
&& python3 scripts/verify-plugin-package.py origin/main \
&& claude plugin validate . --strict \
&& 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/test-verify-polar.py \
&& python3 scripts/verify-polar.py \
&& python3 scripts/verify-sequence-oauth.py \
&& python3 scripts/verify-drawio-import.py \
&& python3 scripts/verify-mermaid-import.py \
&& python3 scripts/test-verify-motion.py \
&& python3 scripts/verify-docs-sync.py \
&& python3 scripts/test-verify-docs-sync.py \
&& python3 scripts/test-self-check.py \
&& python3 scripts/verify-geometry.py --all \
&& python3 scripts/test-verify-geometry.py \
&& python3 scripts/build-icons.py \
&& git diff --ignore-space-at-eol --exit-code -- \
skills/diagram-design/assets/icons.html \
skills/diagram-design/references/primitive-icons.md
```
Expected: exit 0, no failures, no warnings from plugin validation, zero lint/geometry findings, and no generated icon drift.
- [ ] **Step 3: Reconfirm a clean committed snapshot**
```bash
git diff --check origin/main...HEAD
git status --short --branch
```
Expected: no whitespace findings and no working-tree changes. If `build-icons.py` created only line-ending changes, confirm `git diff --ignore-space-at-eol --exit-code` returns 0, then restore only the two generated icon files before reporting completion.
@@ -0,0 +1,167 @@
# Quantitative Polar Chart Design
## Purpose
Add a 28th visual type, **Polar chart**, for one quantitative series whose categories have a meaningful circular order. The type fills the layout gap accepted in issue #60 while narrowing the earlier wheel proposal to a truthful quantitative contract:
- angle encodes category;
- radius encodes magnitude linearly from an explicit zero baseline;
- wedge area encodes nothing;
- static HTML/SVG remains the only shipped output.
The type is a radial lollipop chart, not a profile wheel. Full-radius grid spokes establish the category axes; a value ray from the center and a small endpoint marker encode each observation.
## Scope
Version 1 supports:
- one quantitative series;
- 4–8 equally spaced categories in a meaningful clockwise order;
- a shared linear scale with `min = 0` and `max > 0`;
- five circular grid rings at 20%, 40%, 60%, 80%, and 100%;
- one optional focal category rendered with the accent token;
- minimal light, minimal dark, and full-editorial examples;
- horizontal, upright category labels outside the outer ring;
- one numeric value label per category.
Version 1 excludes:
- DISC, personality, competency, and role-profile wheels whose sectors are qualitative;
- arbitrary point placement within sectors;
- filled quantitative wedges, gradient bands, or donut hubs;
- multiple series, negative values, logarithmic scales, unequal sector angles, and more than eight categories;
- animation or interaction.
Use Radar for multiple entities scored across common criteria, Line for more than eight ordered time buckets, and Bar when circular order adds no meaning.
## Input Contract
```yaml
title: "Request demand by UTC window"
unit: "% of daily peak"
scale:
min: 0
max: 100
categories:
- { label: "00–03", value: 32 }
- { label: "03–06", value: 18 }
- { label: "06–09", value: 24 }
- { label: "09–12", value: 58 }
- { label: "12–15", value: 100, focal: true }
- { label: "15–18", value: 82 }
- { label: "18–21", value: 76 }
- { label: "21–24", value: 45 }
start_angle: -90
clockwise: true
source_note: "Illustrative normalized workload profile"
```
Validation rules:
1. `scale.min` is exactly `0`; truncated radial scales are invalid.
2. `scale.max` is finite and greater than `0`.
3. Every value is finite and satisfies `0 <= value <= scale.max`.
4. Category labels are unique after trimming and category count is 4–8.
5. At most one category is focal.
6. Categories retain input order; sorting by value would destroy circular meaning.
## Quantitative Encoding
For category `i` of `N`, value `v`, center `(cx, cy)`, and outer radius `R`:
```text
theta_i = start_angle + clockwise_sign * 2π * i / N
radius_i = R * v / scale.max
x_i = cx + radius_i * cos(theta_i)
y_i = cy + radius_i * sin(theta_i)
```
Angles are expressed in radians for calculation; `start_angle` is supplied in degrees and defaults to `-90`, placing the first category at twelve o'clock. `clockwise_sign` is `+1` for the default clockwise order and `-1` otherwise.
The value ray is the line segment from `(cx, cy)` to `(x_i, y_i)`. Its visible length is exactly proportional to `v`. Endpoint circles have constant radius and encode no additional quantity. No filled sector may sit behind or beneath a value ray, because its area would compete with the declared radius encoding.
The element carrying `data-polar-chart` must be the chart's root `<svg>`. Its descendants are limited to `<g>`, `<line>`, `<circle>`, `<rect>`, `<text>`, `<title>`, and `<desc>`; nested SVGs, paths, polygons, external references, images, and every other element are rejected. Duplicate attributes, mismatched markup, URL-valued SVG references, transforms, CSS loading through `@import`/`url()`, and geometry-affecting CSS are forbidden. Chart elements carry neither `class` nor inline `style`; document styles may not use selectors capable of matching the chart, except the canonical root `svg` layout rule. The only external stylesheet allowed is Google Fonts. Every line carries exactly one of `data-polar-spoke`, `data-polar-ray`, or `data-polar-rule` and uses the role's declared stroke width; spoke and ray geometry is verified within `min(0.75 px, 5% of R)`. The five grid circles carry `data-polar-ring`, use `fill="none"`, a 0.8 px stroke, the chart center, and the 20% through 100% radius intervals exactly once. Every endpoint circle carries `data-polar-marker` and uses the declared 4 px radius, or 5 px for the focal category, with a 1.2 px stroke. The optional full-canvas background is the only rectangle and carries `data-polar-background`. This fail-closed allowlist prevents contributors from bypassing the radius-only encoding with an unannotated or falsely annotated filled shape.
### Zero
For `v = 0`, `radius_i = 0`. Render no value ray and no endpoint marker. Keep the faint full-radius category spoke, and render the category's numeric label as `0`. A single scale label at the center also marks the shared zero baseline. Do not add a minimum hub, minimum ray length, displaced marker, or other visible magnitude.
### Missing values
Missing values are out of scope for the first version. The agent must stop and ask whether to omit the category or supply a value; it must not coerce missing data to zero.
## Geometry and Layout
All three examples use `viewBox="0 0 1000 520"`, center `(500, 230)`, and `R = 160`.
- Grid rings: `r = 32, 64, 96, 128, 160`.
- Category spokes: center to `R`, 0.8 px rule stroke, no arrowhead.
- Non-focal value rays: 2 px muted stroke; endpoint marker radius 4.
- Focal value ray: 2.4 px accent stroke; endpoint marker radius 5.
- Ring labels: `0.2 × max` through `1.0 × max` on the first axis only, Geist Mono 8 px; the example therefore shows `20, 40, 60, 80, 100`.
- Category labels: at `R + 28`, Geist Sans 11 px semibold, horizontal and upright.
- Numeric labels: at `R + 44`, Geist Mono 8 px, including the unit in the chart subtitle rather than repeating it eight times.
- Label anchor: `middle` within 15 degrees of vertical, `start` on the right half, and `end` on the left half.
- Drawing order: background, rings, spokes, scale labels, non-focal rays, focal ray, endpoint markers, category labels, numeric labels, legend/source note.
The minimal examples use the full 1000×520 canvas. The full-editorial example places the same chart in the existing editorial frame and adds summary cards without changing chart geometry.
## Visual Semantics
- One accent category maximum. All other value rays use the muted token.
- Circular rings and spokes are structural grid marks, never category colors.
- Endpoint markers are constant-size lollipop heads, not bubbles.
- Category labels remain horizontal; tangent rotation is intentionally excluded because it lowers scanability and belongs to the rejected qualitative wheel direction.
- The chart must remain understandable in grayscale: position and numeric labels carry the data; accent only directs attention.
- The SVG accessible description names the peak category, the scale, and the clockwise category order without narrating geometry.
## Example Dataset
All three variants use the input contract above. The values are explicitly illustrative, normalized as a percentage of the daily peak, and make no external factual claim. `12–15` is the focal category at `100`; `03–06` is the minimum at `18`.
The examples intentionally do not include zero because the zero rule is validated by an adversarial verifier fixture rather than by changing the primary editorial story.
## Taxonomy and Documentation
The implementation will:
1. Add `references/type-polar.md` and route “polar chart”, “radial lollipop”, “cyclical magnitude”, and equivalent requests to it.
2. Add **Polar chart** to the SKILL.md visual-type guide and frontmatter description.
3. Change the shipped visual-type count from 27 to 28 across README, SKILL.md, onboarding/reference prose, validation scripts, and ADR 0002.
4. Add the three examples to the gallery as `example-polar.html`, `example-polar-dark.html`, and `example-polar-full.html`.
5. Add a README gallery cell and screenshot for the new type.
6. Use a minor plugin version bump because this adds a public visual type.
ADR 0002 remains valid: semantic patterns do not expand the taxonomy. Its count changes because Polar supplies a genuinely new quantitative circular layout grammar, the exception already allowed by the ADR.
## Machine Verification
Each example's primary SVG carries:
```html
<svg data-polar-chart data-polar-cx="500" data-polar-cy="230"
data-polar-radius="160" data-polar-max="100" ...>
```
Each category is a group with `data-polar-category`, `data-polar-index`, and `data-polar-value`. Positive categories contain one `line[data-polar-ray]` and one `circle[data-polar-marker]`; zero categories contain neither.
A new `scripts/verify-polar.py` gate will:
- require the exact three shipped variants;
- enforce category count, unique indices, shared zero baseline, and at most one focal category;
- recompute every positive endpoint from angle and linearly scaled radius within `min(0.75 px, 5% of R)`;
- reject a ray or endpoint marker for zero;
- allowlist the chart's SVG elements and semantic area-bearing roles, rejecting unannotated or falsely annotated filled shapes, transforms, and non-zero inner baselines;
- confirm all three variants carry identical values and geometry metadata.
`scripts/test-verify-polar.py` will cover a valid chart plus adversarial cases for a 25% hub, square-root radius encoding, a visible zero ray, out-of-range values, duplicate indices, and variant data drift. CI and CONTRIBUTING will run both scripts.
## Acceptance Criteria
- Radius, not area, is the only graphical magnitude encoding.
- A zero-valued category produces zero ray length and remains legible through its numeric label.
- All three examples are static, self-contained, accessible, gallery-reachable, and skin-clean.
- The verifier fails on the known #64 baseline defect and passes the shipped examples.
- The visual-type count and routing vocabulary are consistent across every documented surface.
- Both plugin manifests receive the same minor version.
- The complete CONTRIBUTING validation suite passes on Python 3.11 and 3.12 without adding dependencies.
+18
View File
@@ -0,0 +1,18 @@
---
description: Run one-shot environment diagnostics for Diagram Design readiness
argument-hint: "[--strict] [--json]"
---
Run environment diagnostics for Diagram Design. Locate the available `diagram-design` skill using its `SKILL.md` path advertised by Pi. Read that `SKILL.md`, then read `references/doctor.md` relative to its directory. Treat that reference as the source of truth. Do not assume the package lives under the current working directory.
Full argument string: `$ARGUMENTS`
## Required behavior
1. Run the exact checks and output contract defined in `references/doctor.md`.
2. Keep diagnostics read-only: do not install dependencies, do not edit files, and do not run destructive git commands.
3. If a check command fails, capture stderr, classify as `warn` or `fail` per the reference, and continue remaining checks.
4. Print the summary line plus per-check statuses, and include `Next actions` only when warn/fail exists.
5. If `--json` is passed, append the structured JSON object defined by the reference.
Report only verified results from this run.
+813
View File
@@ -0,0 +1,813 @@
#!/usr/bin/env python3
"""Lint diagram examples as *rendered* — catches breakage the source can't show.
``lint-skin.py`` reads the HTML. This one renders it in headless Chromium and
asks the browser what actually got painted, so it catches content cut off by the
SVG viewport, collapsed SVGs, sideways page overflow and runtime errors.
How the clipping check works
---------------------------
Chromium's ``getBoundingClientRect()`` on an SVG child reports *geometry*, not
paint: it excludes stroke width, markers and filter bleed (so a 40px stroke
spilling past the viewport measures as inside), and it ignores ``clip-path``,
``opacity: 0`` ancestors and ``overflow: visible`` (so safe content measures as
outside). Both directions were reproduced in Chromium, so no geometry model is
used here.
Instead the browser is the oracle: screenshot the viewport as authored,
screenshot it again with ``overflow`` released, and diff the two. New ink means
paint was being cut off — ink is ink, so strokes, markers and filter bleed all
count, while clip-path, invisible and already-visible content produce no new ink.
Releases are **staged**, because a diagram can be clipped at more than one level
and each release repaints its own box:
- stage 0 releases the SVG alone, and looks for ink outside the SVG's box;
- stage *k* also releases the *k* nearest clipping ancestors, and looks for ink
outside the outermost released box.
Without staging, releasing an ancestor masks spill just outside the SVG (its box
becomes part of the ignored area), and an SVG authored ``overflow: visible``
inside a clipping wrapper looks clean while the wrapper cuts it off.
What the diff ignores, and why:
- The outermost released box for that stage. A released element repaints itself —
an ``overflow: hidden`` box with a ``border-radius`` loses its rounded corners —
which is not spill.
- An ``EDGE_GUARD``-px band around it, and anything under ``MIN_DIFF_PIXELS``
pixels past ``CHANNEL_THRESHOLD``. Releasing ``overflow`` re-antialiases
boundary pixels by a channel step or two, which is not a clipped diagram.
Every stage runs at each of ``DIFF_SCALES``: 1x resolves spill of a few px, 0.25x
pulls spill up to four viewports wide back into frame. All geometry stays in one
coordinate system — viewport pixels, straight from ``getBoundingClientRect()``,
matching ``page.screenshot()`` — so an SVG below the fold measures the same as
one above it.
Known ceilings: spill of ~2px or less, spill past 0.25x framing, and SVGs inside
a *scrolling* ancestor, which report ``unmeasurable`` rather than passing quietly.
Every property this script mutates is snapshotted and restored verbatim in a
``finally``, and ``--self-test`` asserts the DOM is byte-identical afterwards.
Trust boundary
--------------
This renders contributor HTML in a real browser with JavaScript enabled, so treat
it like opening the file yourself. Network is cut at the resolver
(``--host-resolver-rules``), which also stops WebSockets and anything else that
does not go through Playwright's request routing, with request routing as a second
layer. ``--fonts`` excludes exactly the two Google Fonts hostnames from the
resolver block and allows them only over HTTPS on an exact hostname match.
``--self-test`` proves the isolation against a local listener.
Because the oracle is pixels, CI must pin Playwright and its bundled Chromium
rather than taking whatever is newest; see ``.github/workflows/ci.yml``.
python3 scripts/lint-render.py --all
python3 scripts/lint-render.py skills/diagram-design/assets/example-venn.html
python3 scripts/lint-render.py --self-test # proves every check still fires
Requires Playwright (same dev dependency the PNG export uses):
pip install playwright && playwright install chromium
"""
import argparse
import base64
import os
import socket
import sys
import tempfile
import threading
from pathlib import Path
from urllib.parse import urlparse
ROOT = Path(__file__).resolve().parent.parent
ASSET_DIR = ROOT / "skills/diagram-design/assets"
VIEWPORT = {"width": 1600, "height": 1000}
TOLERANCE = 1.0 # px of slop before page overflow counts, absorbs subpixel layout
CHANNEL_THRESHOLD = 16 # per-channel delta that counts as new ink, not antialiasing
MIN_DIFF_PIXELS = 8 # new-ink pixels needed before a clip is reported
# ponytail: 2px guard band around a released box absorbs boundary re-antialiasing;
# the cost is that a spill of 2px or less goes unreported.
EDGE_GUARD = 2
# Two passes: 1x sees spills of a few px, 0.25x pulls a spill four viewports wide
# back into frame. ponytail: anything past that stays unseen, and says so above.
DIFF_SCALES = (1, 0.25)
# Deepest ancestor chain to stage. Diagram wrappers are shallow; this only bounds
# pathological nesting. ponytail: raise it if a real example needs more.
MAX_ANCESTOR_STAGES = 4
FONT_HOSTS = ("fonts.googleapis.com", "fonts.gstatic.com")
# Kill name resolution outright. Unlike request routing this also covers
# WebSockets, EventSource and anything else that opens its own connection.
RESOLVER_RULE = "MAP * ~NOTFOUND"
# Findings in these categories are reported but do not fail the run: they say
# "not checked", which is worth printing and wrong to treat as a defect.
NOTE_CATEGORIES = {"unmeasurable"}
# Per-svg facts that don't need paint: its box, whether the author already set
# overflow visible, and the chain of ancestors that clip it. A clipping ancestor
# that is currently scrolling cannot be released (its scrollbars would move and
# change the pixels), so the chain stops there and the rest is reported unchecked.
SURVEY_JS = """
() => {
const label = (el) => {
const id = el.id ? '#' + el.id : '';
const cls = el.getAttribute('class') ? '.' + el.getAttribute('class').split(/\\s+/)[0] : '';
return el.tagName.toLowerCase() + id + cls;
};
return [...document.querySelectorAll('svg')].map((svg, index) => {
const box = svg.getBoundingClientRect();
const chain = [];
let blockedBy = null;
for (let el = svg.parentElement; el; el = el.parentElement) {
const overflow = getComputedStyle(el).overflow;
if (overflow === 'visible') continue;
const scrolls = el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1;
if (scrolls) { blockedBy = `${label(el)} (overflow: ${overflow})`; break; }
chain.push(`${label(el)} (overflow: ${overflow})`);
}
return {
index,
label: label(svg),
width: box.width,
height: box.height,
ownOverflowVisible: getComputedStyle(svg).overflow === 'visible',
chain,
blockedBy,
};
});
}
"""
# Mutations record the exact `style` attribute they replace, so RESTORE_JS can put
# it back verbatim — including an authored inline transform, which an earlier
# version clobbered by resetting the property to ''.
REMEMBER_JS = """
window.__lintSnapshots = window.__lintSnapshots || [];
const remember = (el) => window.__lintSnapshots.push([el, el.getAttribute('style')]);
"""
RESTORE_JS = """
() => {
const snapshots = window.__lintSnapshots || [];
// Reverse order, so an element mutated twice ends on its earliest snapshot.
for (const [el, style] of snapshots.slice().reverse()) {
if (style === null) el.removeAttribute('style');
else el.setAttribute('style', style);
}
delete window.__lintSnapshots;
}
"""
# A paint-only scale on the svg. Transforms don't reflow, so the rest of the page
# stays put and both screenshots of a pass are directly comparable. Shrinking the
# svg pulls spill that would land off-screen back into the viewport.
SET_SCALE_JS = (
"""
([index, scale]) => {
"""
+ REMEMBER_JS
+ """
const svg = document.querySelectorAll('svg')[index];
remember(svg);
svg.style.setProperty('transform-origin', 'top left', 'important');
svg.style.setProperty('transform', `scale(${scale})`, 'important');
}
"""
)
# Release overflow on the svg plus the `depth` nearest clipping ancestors, and
# return every released box in viewport coordinates, innermost first. `important`
# so a class rule can't win; the exact prior style attribute is snapshotted.
RELEASE_JS = (
"""
([index, depth]) => {
"""
+ REMEMBER_JS
+ """
const svg = document.querySelectorAll('svg')[index];
const targets = [svg];
let taken = 0;
for (let el = svg.parentElement; el && taken < depth; el = el.parentElement) {
if (getComputedStyle(el).overflow === 'visible') continue;
targets.push(el);
taken++;
}
const rects = [];
for (const el of targets) {
remember(el);
const r = el.getBoundingClientRect();
rects.push([r.left, r.top, r.right, r.bottom]);
el.style.setProperty('overflow', 'visible', 'important');
}
return rects;
}
"""
)
DOM_STATE_JS = "() => document.documentElement.outerHTML"
PAGE_OVERFLOW_JS = """
() => {
const doc = document.documentElement;
return [doc.scrollWidth - doc.clientWidth, doc.clientWidth];
}
"""
# Diff two screenshots of the same viewport inside the browser: no image library
# on the Python side. Only ink OUTSIDE the released box counts, so an element
# repainting its own area can't masquerade as clipping.
DIFF_JS = """
async ({a, b, interior, threshold, minPixels, guard}) => {
const load = async (src) => {
const bitmap = await createImageBitmap(await (await fetch(src)).blob());
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
canvas.getContext('2d').drawImage(bitmap, 0, 0);
return canvas.getContext('2d').getImageData(0, 0, bitmap.width, bitmap.height);
};
const [before, after] = await Promise.all([load(a), load(b)]);
if (before.width !== after.width || before.height !== after.height) {
return {count: 0, resized: true, sides: {}};
}
const sides = {left: 0, top: 0, right: 0, bottom: 0};
let count = 0;
const g = guard;
for (let y = 0; y < before.height; y++) {
for (let x = 0; x < before.width; x++) {
if (x >= interior.left - g && x < interior.right + g &&
y >= interior.top - g && y < interior.bottom + g) continue;
const i = (y * before.width + x) * 4;
const delta = Math.max(
Math.abs(before.data[i] - after.data[i]),
Math.abs(before.data[i + 1] - after.data[i + 1]),
Math.abs(before.data[i + 2] - after.data[i + 2]),
Math.abs(before.data[i + 3] - after.data[i + 3]),
);
if (delta <= threshold) continue;
count++;
if (x < interior.left) sides.left = Math.max(sides.left, interior.left - x);
if (x >= interior.right) sides.right = Math.max(sides.right, x - interior.right + 1);
if (y < interior.top) sides.top = Math.max(sides.top, interior.top - y);
if (y >= interior.bottom) sides.bottom = Math.max(sides.bottom, y - interior.bottom + 1);
}
}
return {count, resized: false, sides, reported: count >= minPixels};
}
"""
def parse_args():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("files", nargs="*", type=Path, help="HTML files to render")
parser.add_argument("--all", action="store_true", help="render every example")
parser.add_argument("--quiet", action="store_true", help="summary only")
parser.add_argument(
"--fonts",
action="store_true",
help="allow exactly the Google Fonts hosts, over HTTPS, so text is measured "
"with the real typefaces",
)
parser.add_argument(
"--self-test",
action="store_true",
dest="self_test",
help="check the checks: detection, false flags, network isolation, DOM restore",
)
return parser.parse_args()
def shipped_assets():
"""Every asset that ships and renders: examples *and* the templates they are
scaffolded from. A broken template ships broken diagrams, so leaving templates
out of --all was a hole (thanks @greptile). index.html and icons.html are
galleries of the others, so they add no coverage.
"""
patterns = ("example-*.html", "template*.html")
return sorted({path for pattern in patterns for path in ASSET_DIR.glob(pattern)})
def display_path(path):
try:
return str(path.resolve().relative_to(ROOT))
except ValueError:
return str(path)
def resolver_rule(allow_fonts):
"""Block every hostname, minus the font hosts when they are opted in."""
if not allow_fonts:
return RESOLVER_RULE
return ",".join([RESOLVER_RULE] + [f"EXCLUDE {host}" for host in FONT_HOSTS])
def launch(playwright, allow_fonts):
"""Bundled Chromium, or the channel named by DIAGRAM_LINT_BROWSER_CHANNEL.
No silent fallback: in CI a missing bundled Chromium is a broken environment,
and quietly using whatever browser is lying around hides that.
"""
args = [f"--host-resolver-rules={resolver_rule(allow_fonts)}"]
channel = os.environ.get("DIAGRAM_LINT_BROWSER_CHANNEL")
if channel:
print(f"Using browser channel {channel!r} (DIAGRAM_LINT_BROWSER_CHANNEL).", file=sys.stderr)
return playwright.chromium.launch(channel=channel, args=args)
return playwright.chromium.launch(args=args)
def block_network(context, allow_fonts):
"""Second layer behind the resolver block: local documents only.
The font exception requires HTTPS and an exact hostname, so a URL merely
containing "fonts.googleapis.com" (a path, a query, a lookalike host) is not
enough to get through.
"""
def handler(route):
url = route.request.url
if url.startswith(("file://", "data:", "blob:")):
route.continue_()
return
parsed = urlparse(url)
if allow_fonts and parsed.scheme == "https" and parsed.hostname in FONT_HOSTS:
route.continue_()
return
route.abort()
context.route("**/*", handler)
def watch(page, findings):
"""Runtime failures. Local assets are reported precisely, via requestfailed."""
page.on("pageerror", lambda error: findings.append(("page-error", str(error))))
page.on(
"console",
lambda message: message.type == "error"
# Resource failures arrive on requestfailed with a URL, which is where they
# get judged. This drops the duplicate, not the signal.
and not message.text.startswith("Failed to load resource")
and findings.append(("console-error", message.text)),
)
def on_request_failed(request):
if request.url.startswith("file://"):
findings.append(("missing-asset", f"{request.url} failed to load"))
# Remote requests are blocked by policy (resolver + routing); that is this
# linter's doing, not the diagram's.
page.on("requestfailed", on_request_failed)
def shoot(page):
"""The viewport, not the page: fixed dimensions, so releasing overflow can't
resize the image, and the whole visible area is available to spill into.
Viewport pixels are also the coordinate system every rect here uses."""
return page.screenshot(animations="disabled")
def data_url(png_bytes):
return "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii")
def compare_release(page, index, depth, scale):
"""One authored-vs-released paint comparison. Returns (diff, released_label_depth).
Restores every mutated property before returning, whatever happens.
"""
try:
if scale != 1:
page.evaluate(SET_SCALE_JS, [index, scale])
as_authored = shoot(page)
rects = page.evaluate(RELEASE_JS, [index, depth])
released = shoot(page)
finally:
page.evaluate(RESTORE_JS)
if not rects:
return None
# Outermost released box: the only area whose own repaint is expected.
left, top, right, bottom = rects[-1]
interior = {
"left": round(left),
"top": round(top),
"right": round(right),
"bottom": round(bottom),
}
return page.evaluate(
DIFF_JS,
{
"a": data_url(as_authored),
"b": data_url(released),
"interior": interior,
"threshold": CHANNEL_THRESHOLD,
"minPixels": MIN_DIFF_PIXELS,
"guard": EDGE_GUARD,
},
)
def clipping_findings(page, survey):
"""Staged paint comparisons per svg: the svg alone, then each clipping ancestor."""
findings = []
handles = page.locator("svg")
for entry in survey:
if not entry["width"] or not entry["height"]:
findings.append(
("svg-collapsed", f"{entry['label']} renders {entry['width']}x{entry['height']}")
)
continue
if entry["blockedBy"]:
findings.append(
(
"unmeasurable",
f"{entry['label']} sits inside scrolling {entry['blockedBy']}; "
"clipping cannot be measured through it",
)
)
chain = entry["chain"][:MAX_ANCESTOR_STAGES]
# Stage 0 is the svg's own overflow — skipped only when the author already
# set it visible. Ancestor stages run regardless, since a wrapper can clip
# an svg that does not clip itself.
stages = [] if entry["ownOverflowVisible"] else [0]
stages += list(range(1, len(chain) + 1))
if not stages:
continue
handles.nth(entry["index"]).scroll_into_view_if_needed()
for depth in stages:
where = entry["label"] if depth == 0 else f"{entry['label']} inside {chain[depth - 1]}"
worst = None
unmeasurable = False
for scale in DIFF_SCALES:
diff = compare_release(page, entry["index"], depth, scale)
if diff is None:
continue
if diff.get("resized"):
findings.append(
("unmeasurable", f"{where} changed size when overflow was released")
)
unmeasurable = True
break
if diff.get("reported") and (worst is None or diff["count"] > worst[0]["count"]):
worst = (diff, scale)
if unmeasurable or not worst:
continue
diff, scale = worst
spills = ", ".join(
f"{side} by {int(distance / scale)}px"
for side, distance in sorted(diff["sides"].items())
if distance
)
findings.append(
(
"clipped",
f"{where} paints outside its box: {spills} "
f"({diff['count']} px of ink cut off at {scale:g}x)",
)
)
# One report per svg is enough; deeper stages describe the same spill.
break
return findings
def measure(page):
findings = []
# Playwright awaits a returned promise; no timeout kwarg exists on evaluate.
page.evaluate("() => document.fonts.ready")
survey = page.evaluate(SURVEY_JS)
findings.extend(clipping_findings(page, survey))
spill, client_width = page.evaluate(PAGE_OVERFLOW_JS)
if spill > TOLERANCE:
findings.append(
("page-overflow", f"page scrolls {spill:.0f}px horizontally at {client_width}px wide")
)
return findings
def check(page, path):
findings = []
watch(page, findings)
page.goto(path.resolve().as_uri(), wait_until="load")
return measure(page) + findings
# --- self-test ---------------------------------------------------------------
SVG_CASES = [
# (name, svg markup, svg inline style, wrapper html or None, should_be_flagged)
("plain-overflow", '<rect x="150" y="60" width="400" height="10" fill="#000"/>', "", None, True),
(
"thick-stroke-spill",
'<rect x="150" y="20" width="45" height="40" fill="none" stroke="#000" stroke-width="40"/>',
"",
None,
True,
),
(
"marker-spill",
'<defs><marker id="a" markerWidth="30" markerHeight="30" refX="0" refY="5" overflow="visible">'
'<path d="M0,0 L30,5 L0,10 Z" fill="#000"/></marker></defs>'
'<line x1="100" y1="50" x2="199" y2="50" stroke="#000" marker-end="url(#a)"/>',
"",
None,
True,
),
(
"filter-bleed",
'<filter id="b" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="20"/></filter>'
'<rect x="165" y="10" width="30" height="20" fill="#000" filter="url(#b)"/>',
"",
None,
True,
),
(
"clip-path-keeps-it-safe",
'<clipPath id="c"><rect x="0" y="0" width="200" height="100"/></clipPath>'
'<rect x="150" y="10" width="400" height="30" fill="#000" clip-path="url(#c)"/>',
"",
None,
False,
),
(
"opacity-0-ancestor",
'<g opacity="0"><rect x="150" y="10" width="400" height="10" fill="#000"/></g>',
"",
None,
False,
),
(
"display-none-ancestor",
'<g style="display:none"><rect x="150" y="10" width="400" height="10" fill="#000"/></g>',
"",
None,
False,
),
("all-inside", '<rect x="10" y="10" width="100" height="40" fill="#000"/>', "", None, False),
# The author set overflow visible: the svg itself clips nothing.
(
"overflow-visible-svg",
'<rect x="150" y="60" width="400" height="10" fill="#000"/>',
"overflow:visible",
None,
False,
),
# ... but a wrapper still clips it, and staging must catch that.
(
"overflow-visible-svg-in-clipping-wrapper",
'<rect x="150" y="60" width="400" height="10" fill="#000"/>',
"overflow:visible",
'<div style="overflow:hidden;width:200px;height:100px">',
True,
),
# Wrapper clipping declared inline, and via a class — both must be released.
(
"inline-wrapper",
'<rect x="150" y="60" width="400" height="10" fill="#000"/>',
"",
'<div style="overflow:hidden;width:200px;height:100px">',
True,
),
(
"class-wrapper",
'<rect x="150" y="60" width="400" height="10" fill="#000"/>',
"",
'<style>.wrap{overflow:hidden;width:200px;height:100px}</style><div class="wrap">',
True,
),
# A rounded clipping wrapper repaints its corners when released; not spill.
(
"rounded-wrapper",
'<rect x="10" y="10" width="100" height="40" fill="#000"/>',
"background:#eee",
'<div style="overflow:hidden;border-radius:12px;padding:40px;background:#222;width:280px">',
False,
),
# An authored inline transform must survive the scale pass untouched.
(
"authored-transform",
'<rect x="150" y="60" width="400" height="10" fill="#000"/>',
"transform:rotate(2deg)",
None,
True,
),
]
FIXTURE_PAGE = """
<!DOCTYPE html><html><body style="margin:40px">%(filler)s%(open)s
<svg width="200" height="100" viewBox="0 0 200 100" style="display:block;%(svg_style)s">%(markup)s</svg>
%(close)s</body></html>
"""
# Enough filler to push the svg below a 1000px-tall viewport, so the paired
# above/below-fold cases exercise scrolled measurement.
BELOW_FOLD_FILLER = '<div style="height:1800px;background:#eee"></div>'
def fixture_html(markup, svg_style="", wrapper=None, filler=""):
return FIXTURE_PAGE % {
"filler": filler,
"open": wrapper or "",
"close": "</div>" if wrapper else "",
"svg_style": svg_style,
"markup": markup,
}
def network_isolation_failures(context):
"""Point a page at a local listener four different ways; nothing may connect.
Request routing alone does not stop a WebSocket — verified — which is why the
resolver block exists. This asserts the boundary rather than documenting it.
"""
connections = []
server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 0))
port = server.getsockname()[1]
server.listen(8)
def accept_loop():
while True:
try:
connection, address = server.accept()
except OSError:
return
connections.append(address)
connection.close()
thread = threading.Thread(target=accept_loop, daemon=True)
thread.start()
page = context.new_page()
try:
with tempfile.TemporaryDirectory() as directory:
fixture = Path(directory) / "isolation.html"
fixture.write_text(
"<!DOCTYPE html><html><body><script>\n"
f" const target = '127.0.0.1:{port}';\n"
" try { new WebSocket('ws://' + target + '/ws'); } catch (e) {}\n"
" try { fetch('http://' + target + '/fetch').catch(() => {}); } catch (e) {}\n"
" try { new EventSource('http://' + target + '/sse'); } catch (e) {}\n"
" const img = new Image(); img.src = 'http://' + target + '/img.png';\n"
"</script></body></html>",
encoding="utf-8",
)
page.goto(fixture.as_uri(), wait_until="load")
page.wait_for_timeout(1200)
finally:
page.close()
server.close()
if connections:
return [f"network-isolation: {len(connections)} connection(s) reached a local listener"]
return []
def self_test(context):
page = context.new_page()
failures = []
checks = 0
def flagged(html):
page.set_content(html)
return any(category == "clipped" for category, _ in measure(page))
for name, markup, svg_style, wrapper, expected in SVG_CASES:
checks += 1
if flagged(fixture_html(markup, svg_style, wrapper)) != expected:
failures.append(f"{name}: flagged={not expected}, expected={expected}")
# Paired above/below-fold: identical spill, so a scrolled measurement that
# mixed document and viewport coordinates would disagree with its twin.
for label, filler in (("above-fold", ""), ("below-fold", BELOW_FOLD_FILLER)):
checks += 1
if not flagged(fixture_html(SVG_CASES[0][1], filler=filler)):
failures.append(f"{label}-spill: not caught")
checks += 1
if flagged(fixture_html(SVG_CASES[7][1], filler=BELOW_FOLD_FILLER)):
failures.append("below-fold-clean: false clipped finding")
# The DOM must come back exactly as authored, including inline transforms.
checks += 1
page.set_content(fixture_html(SVG_CASES[0][1], svg_style="transform:rotate(2deg)"))
before = page.evaluate(DOM_STATE_JS)
measure(page)
if page.evaluate(DOM_STATE_JS) != before:
failures.append("dom-restore: markup changed after measuring")
# A *scrolling* ancestor must be reported unmeasurable, not clean.
checks += 1
page.set_content(
fixture_html(SVG_CASES[0][1], wrapper='<div style="overflow:auto;width:120px">')
)
if not any(category == "unmeasurable" for category, _ in measure(page)):
failures.append("scrolling-ancestor: no unmeasurable finding")
# Collapsed svg, and a page that scrolls sideways.
checks += 2
page.set_content(
'<!DOCTYPE html><html><body style="margin:0"><div style="width:3000px">wide</div>'
'<svg width="0" height="0" viewBox="0 0 200 100"><rect width="10" height="10"/></svg></body></html>'
)
categories = {category for category, _ in measure(page)}
for expected_category in ("svg-collapsed", "page-overflow"):
if expected_category not in categories:
failures.append(f"{expected_category}: check did not fire")
page.close()
# A missing local asset must surface, not be swallowed with the remote noise.
checks += 1
asset_page = context.new_page()
asset_findings = []
watch(asset_page, asset_findings)
with tempfile.TemporaryDirectory() as directory:
fixture = Path(directory) / "missing-asset.html"
fixture.write_text('<img src="does-not-exist-12345.png">', encoding="utf-8")
asset_page.goto(fixture.as_uri(), wait_until="load")
asset_page.wait_for_timeout(300)
asset_page.close()
if not any(category == "missing-asset" for category, _ in asset_findings):
failures.append("missing-asset: check did not fire")
checks += 1
failures += network_isolation_failures(context)
if failures:
print("self-test FAILED:")
for failure in failures:
print(f" {failure}")
return 1
print(f"self-test OK: {checks} cases, no false flags.")
return 0
def main():
args = parse_args()
try:
from playwright.sync_api import sync_playwright
except ImportError:
print(
"Playwright is not installed. Install it with:\n"
" pip install playwright && playwright install chromium",
file=sys.stderr,
)
return 2
paths = shipped_assets() if args.all else args.files
if not paths and not args.self_test:
print("Nothing to check. Pass files, --all or --self-test.", file=sys.stderr)
return 2
total_findings = 0
total_notes = 0
with sync_playwright() as playwright:
try:
browser = launch(playwright, args.fonts)
except Exception as error:
print(
f"Could not launch a browser: {error}\n"
" playwright install chromium\n"
" (or set DIAGRAM_LINT_BROWSER_CHANNEL=chrome to use a system Chrome)",
file=sys.stderr,
)
return 2
# The oracle is pixels, so record which browser produced them.
print(f"Chromium {browser.version}, network: {resolver_rule(args.fonts)}", file=sys.stderr)
context = browser.new_context(viewport=VIEWPORT)
block_network(context, args.fonts)
if args.self_test:
status = self_test(context)
if status or not paths:
browser.close()
return status
for path in paths:
page = context.new_page()
try:
findings = check(page, path)
except Exception as error:
findings = [("render-error", str(error).splitlines()[0])]
finally:
page.close()
findings.sort()
total_findings += sum(1 for category, _ in findings if category not in NOTE_CATEGORIES)
total_notes += sum(1 for category, _ in findings if category in NOTE_CATEGORIES)
if not args.quiet:
shown_path = display_path(path)
for category, message in findings:
print(f"{shown_path}: {category}: {message}")
browser.close()
print(
f"Summary: {len(paths)} file(s) rendered, {total_findings} finding(s), "
f"{total_notes} note(s) (not checked, not failed)."
)
return 1 if total_findings else 0
if __name__ == "__main__":
sys.exit(main())
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Render the 38 canonical minimal-light examples and record their digests.""" """Render the 39 canonical minimal-light examples and record their digests."""
from __future__ import annotations from __future__ import annotations
@@ -21,8 +21,8 @@ from screenshot_catalog import (
def main() -> int: def main() -> int:
slugs = canonical_slugs() slugs = canonical_slugs()
if len(slugs) != 38 or len(slugs) != len(set(slugs)): if len(slugs) != 39 or len(slugs) != len(set(slugs)):
raise SystemExit(f"expected 38 unique canonical types; found {len(slugs)}") raise SystemExit(f"expected 39 unique canonical types; found {len(slugs)}")
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True) SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
entries: list[dict[str, object]] = [] entries: list[dict[str, object]] = []
@@ -56,7 +56,7 @@ def main() -> int:
"height": height, "height": height,
} }
) )
print(f"[{index:02d}/38] {slug}: {width}x{height}") print(f"[{index:02d}/39] {slug}: {width}x{height}")
browser.close() browser.close()
payload = { payload = {
+175 -18
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Regression tests for docs links and profile-surface verification.""" """Regression tests for docs links and routing-surface verification."""
from __future__ import annotations from __future__ import annotations
@@ -13,6 +13,37 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
VERIFY = ROOT / "scripts" / "verify-docs-sync.py" VERIFY = ROOT / "scripts" / "verify-docs-sync.py"
HIGH_LEVEL_REFERENCE = """\
## 2. Layout formulas — deterministic geometry
### 2.1 Canvas
```
has_vertical = any(c.vertical for c in chevrons)
right_strip_w = 28 if has_vertical else 0
strip_margin = 8 if has_vertical else 0
effective_w = 1000 - right_strip_w - strip_margin
```
## 7. Reproducibility checklist (the taste gate)
1. Check one.
2. Check two.
3. If a vertical chevron exists, `effective_w = 964`; otherwise, `effective_w = 1000`.
4. Check four.
5. Check five.
6. Check six.
7. Check seven.
8. Check eight.
9. Check nine.
10. Check ten.
11. Check eleven.
12. Check twelve.
13. Check thirteen.
## 8. Anti-patterns
"""
def load_verify_module(): def load_verify_module():
sys.dont_write_bytecode = True sys.dont_write_bytecode = True
@@ -144,34 +175,71 @@ From a repository checkout, run `python3 <repo-root>/scripts/verify-geometry.py
root = Path(temp_dir) / "repo" root = Path(temp_dir) / "repo"
profile_reference = root / "skills/diagram-design/references/profiles.md" profile_reference = root / "skills/diagram-design/references/profiles.md"
command = root / "commands/profile.md" doctor_reference = root / "skills/diagram-design/references/doctor.md"
prompt = root / "prompts/profile.md" export_reference = root / "skills/diagram-design/references/export.md"
for path in (profile_reference, command, prompt): drawio_reference = root / "skills/diagram-design/references/import-drawio.md"
mermaid_reference = root / "skills/diagram-design/references/import-mermaid.md"
export_command = root / "commands/export-diagram.md"
drawio_command = root / "commands/import-drawio.md"
mermaid_command = root / "commands/import-mermaid.md"
profile_command = root / "commands/profile.md"
doctor_command = root / "commands/doctor.md"
export_prompt = root / "prompts/export-diagram.md"
mermaid_prompt = root / "prompts/import-mermaid.md"
profile_prompt = root / "prompts/profile.md"
doctor_prompt = root / "prompts/doctor.md"
for path in (
profile_reference,
doctor_reference,
export_reference,
drawio_reference,
mermaid_reference,
export_command,
drawio_command,
mermaid_command,
profile_command,
doctor_command,
export_prompt,
mermaid_prompt,
profile_prompt,
doctor_prompt,
):
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
profile_reference.write_text("# Profiles\n", encoding="utf-8") profile_reference.write_text("# Profiles\n", encoding="utf-8")
command.write_text("Follow references/profiles.md.\n", encoding="utf-8") doctor_reference.write_text("# Doctor\n", encoding="utf-8")
prompt.write_text("Follow references/profiles.md.\n", encoding="utf-8") export_reference.write_text("# Export\n", encoding="utf-8")
drawio_reference.write_text("# Draw.io\n", encoding="utf-8")
mermaid_reference.write_text("# Mermaid\n", encoding="utf-8")
export_command.write_text("Follow references/export.md.\n", encoding="utf-8")
drawio_command.write_text("Follow references/import-drawio.md.\n", encoding="utf-8")
mermaid_command.write_text("Follow references/import-mermaid.md.\n", encoding="utf-8")
profile_command.write_text("Follow references/profiles.md.\n", encoding="utf-8")
doctor_command.write_text("Follow references/doctor.md.\n", encoding="utf-8")
export_prompt.write_text("Follow references/export.md.\n", encoding="utf-8")
mermaid_prompt.write_text("Follow references/import-mermaid.md.\n", encoding="utf-8")
profile_prompt.write_text("Follow references/profiles.md.\n", encoding="utf-8")
doctor_prompt.write_text("Follow references/doctor.md.\n", encoding="utf-8")
errors = [] errors = []
verify.check_profile_surfaces(errors, root) verify.check_routing_surfaces(errors, root)
if errors: if errors:
raise AssertionError(f"valid profile surfaces failed: {errors}") raise AssertionError(f"valid routing surfaces failed: {errors}")
prompt.unlink() doctor_prompt.unlink()
errors = [] errors = []
verify.check_profile_surfaces(errors, root) verify.check_routing_surfaces(errors, root)
expected = "profile surface is missing: prompts/profile.md" expected = "routing surface is missing: prompts/doctor.md"
if errors != [expected]: if errors != [expected]:
raise AssertionError(f"missing Pi prompt was not reported: {errors}") raise AssertionError(f"missing routing prompt was not reported: {errors}")
prompt.write_text("Stale standalone instructions.\n", encoding="utf-8") doctor_prompt.write_text("Stale standalone instructions.\n", encoding="utf-8")
errors = [] errors = []
verify.check_profile_surfaces(errors, root) verify.check_routing_surfaces(errors, root)
expected = ( expected = (
"profile surface does not route to references/profiles.md: prompts/profile.md" "routing surface does not route to references/doctor.md: prompts/doctor.md"
) )
if errors != [expected]: if errors != [expected]:
raise AssertionError(f"stale Pi prompt was not reported: {errors}") raise AssertionError(f"stale routing prompt was not reported: {errors}")
factory_manifest = root / ".factory-plugin/plugin.json" factory_manifest = root / ".factory-plugin/plugin.json"
factory_marketplace = root / ".factory-plugin/marketplace.json" factory_marketplace = root / ".factory-plugin/marketplace.json"
@@ -262,9 +330,98 @@ diagram-design/
if errors != [expected]: if errors != [expected]:
raise AssertionError(f"missing Factory native path was not reported: {errors}") raise AssertionError(f"missing Factory native path was not reported: {errors}")
counted = root / "commands"
counted.mkdir(parents=True, exist_ok=True)
drawio = counted / "import-drawio.md"
mermaid = counted / "import-mermaid.md"
routed = "`--type` forces one of the visual types in SKILL.md \u00a73.\n"
for path in (drawio, mermaid):
path.write_text(routed, encoding="utf-8")
errors = []
verify.check_type_counts(errors, root)
if errors:
raise AssertionError(f"a command pointing at SKILL.md failed: {errors}")
# The stale wording the gate was written for, the same wording wrapped
# across the line the real commands wrap on, and the rewrites a later
# edit would reach for. Each is the only defect in the tree, so the gate
# has to report exactly one error.
for stale in (
"`--type` forces one of the 27.\n",
"`--type` forces one of the\n27 visual types.\n",
"`--type` forces one of 28.\n",
"The skill draws 28 visual types.\n",
"The skill draws 28 supported visual diagram types.\n",
"The skill supports 28 types of visual diagrams.\n",
):
mermaid.write_text(stale, encoding="utf-8")
errors = []
verify.check_type_counts(errors, root)
if len(errors) != 1 or "hardcodes the visual-type count" not in errors[0]:
raise AssertionError(
f"a hardcoded count was not reported for {stale!r}: {errors}"
)
# Counts that are not the visual-type count must pass. A gate that
# rejects "accepts 2 file types" is one contributors route around, and
# both commands already carry unrelated numbers in their flag docs.
for benign in (
"`--type` accepts 2 file types.\n",
"Produces 3 output types.\n",
"`--detail=faithful` allows 24 nodes.\n",
"Reads 2 types of visual file.\n",
):
mermaid.write_text(routed + benign, encoding="utf-8")
errors = []
verify.check_type_counts(errors, root)
if errors:
raise AssertionError(
f"a count unrelated to the taxonomy was rejected for {benign!r}: {errors}"
)
# Restore the routed wording first: leaving a stale count behind lets
# this case pass on the wrong error and never names the missing surface.
mermaid.write_text(routed, encoding="utf-8")
drawio.unlink()
errors = []
verify.check_type_counts(errors, root)
expected = "type-count surface is missing: commands/import-drawio.md"
if errors != [expected]:
raise AssertionError(f"a missing command surface was not reported: {errors}")
errors = []
verify.check_high_level_reference(errors, HIGH_LEVEL_REFERENCE)
if errors:
raise AssertionError(f"valid High-Level reference failed: {errors}")
errors = []
verify.check_high_level_reference(
errors,
HIGH_LEVEL_REFERENCE.replace("effective_w = 964", "effective_w = 972", 1),
)
expected = (
"High-Level checklist item 3 has effective_w=972; "
"expected 1000 - 28 - 8 = 964"
)
if errors != [expected]:
raise AssertionError(f"stale effective width was not reported: {errors}")
errors = []
verify.check_high_level_reference(
errors,
HIGH_LEVEL_REFERENCE.replace("13. Check thirteen.", "11. Check thirteen."),
)
expected = (
"High-Level reproducibility checklist numbering is not sequential: "
"expected 1..13, found 1,2,3,4,5,6,7,8,9,10,11,12,11"
)
if errors != [expected]:
raise AssertionError(f"duplicate checklist number was not reported: {errors}")
print( print(
"PASS: docs sync checks references, strict-bundler packaging, profile surfaces, " "PASS: docs sync checks references, strict-bundler packaging, routing surfaces, "
"and Factory install contract" "Factory install contract, type-count routing, and High-Level invariants"
) )
return 0 return 0
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Adversarial tests for verify-doctor.py."""
from __future__ import annotations
import contextlib
import importlib.util
import io
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VERIFY = ROOT / "scripts" / "verify-doctor.py"
def load_verify_module():
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location("diagram_design_verify_doctor", VERIFY)
if spec is None or spec.loader is None:
raise AssertionError("could not load verify-doctor.py")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def touch(path: Path, content: str = "placeholder\n") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def seed_repo(module, root: Path) -> None:
touch(root / "skills/diagram-design/SKILL.md", "# Skill\n")
for relative in module.MAINTAINER_MARKERS:
touch(root / relative)
for relative in module.EXPECTED_SCRIPTS:
touch(root / relative)
for relative, reference in module.ROUTING_SURFACES.items():
touch(root / relative, f"Routes to {reference}.\n")
def expect_status(check, status: str, needle: str) -> None:
if check.status != status or needle not in check.message:
raise AssertionError(
f"expected ({status}, contains {needle!r}); got ({check.status}, {check.message!r})"
)
def main() -> int:
verify = load_verify_module()
with tempfile.TemporaryDirectory(prefix="verify-doctor-") as temp_dir:
root = Path(temp_dir)
seed_repo(verify, root)
check = verify.check_expected_scripts(root)
expect_status(check, verify.PASS, "All required scripts are present")
print("OK: expected scripts pass when all are present")
missing = root / verify.EXPECTED_SCRIPTS[0]
missing.unlink()
check = verify.check_expected_scripts(root)
expect_status(check, verify.FAIL, "Missing required script")
print("OK: missing expected script fails")
touch(missing)
check = verify.check_routing_surfaces(root)
expect_status(check, verify.PASS, "routing surfaces")
print("OK: routing surfaces pass when all are wired")
broken_surface = root / next(iter(verify.ROUTING_SURFACES.keys()))
broken_surface.write_text("stale standalone instructions\n", encoding="utf-8")
check = verify.check_routing_surfaces(root)
expect_status(check, verify.FAIL, "reference mismatches")
print("OK: routing mismatch fails")
check = verify.check_common_path_mistakes(root, root)
if check.status not in (verify.PASS, verify.WARN):
raise AssertionError(f"unexpected common path status: {check.status}")
print("OK: common path check returns non-fail in normal repository roots")
installed_root = root / "installed-skill"
touch(installed_root / "SKILL.md", "# Installed skill\n")
unrelated_project = root / "user-project"
unrelated_project.mkdir()
if verify.is_maintainer_checkout(installed_root):
raise AssertionError("standalone skill was misidentified as a maintainer checkout")
for installed_check in (
verify.check_expected_scripts(installed_root),
verify.check_routing_surfaces(installed_root),
verify.check_common_path_mistakes(installed_root, unrelated_project),
):
if installed_check.status != verify.PASS:
raise AssertionError(
"healthy installed skill outside the maintainer repository did not pass: "
f"{installed_check}"
)
print("OK: installed skill in an arbitrary user project does not require maintainer files")
summary_checks = [
verify.CheckResult("a", verify.PASS, "ok"),
verify.CheckResult("b", verify.WARN, "warn", "fix warn"),
]
status, counts, exit_code = verify.summarize(summary_checks, strict=False)
if (status, counts[verify.WARN], exit_code) != ("WARN", 1, 0):
raise AssertionError("non-strict summarize did not preserve WARN semantics")
status, counts, exit_code = verify.summarize(summary_checks, strict=True)
if (status, counts[verify.WARN], exit_code) != ("WARN", 1, 1):
raise AssertionError("strict summarize did not treat WARN as failure")
print("OK: strict mode elevates WARN to failing exit code")
report_out = io.StringIO()
with contextlib.redirect_stdout(report_out):
code = verify.print_report(summary_checks, strict=True, emit_json=True)
report_text = report_out.getvalue()
if code != 1:
raise AssertionError(f"expected strict WARN report to exit 1, got {code}")
for needle in (
"Doctor summary: WARN",
"Next actions",
'"status": "WARN"',
'"checks"',
):
if needle not in report_text:
raise AssertionError(f"report missing {needle!r}")
print("OK: report prints summary, next actions, and JSON payload")
print("All doctor verifier tests passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2 -2
View File
@@ -402,7 +402,7 @@ def main() -> int:
missing_guide = directory / "missing-guide.md" missing_guide = directory / "missing-guide.md"
missing_guide.write_text( missing_guide.write_text(
original_skill.read_text(encoding="utf-8").replace( original_skill.read_text(encoding="utf-8").replace(
"### Visual-type guide (38)", "### Visual guide" "### Visual-type guide (39)", "### Visual guide"
), ),
encoding="utf-8", encoding="utf-8",
) )
@@ -410,7 +410,7 @@ def main() -> int:
markdown_errors = semantic_module.verify_markdown() markdown_errors = semantic_module.verify_markdown()
finally: finally:
semantic_module.SKILL = original_skill semantic_module.SKILL = original_skill
if not any("must contain the 38-row visual-type guide" in error for error in markdown_errors): if not any("must contain the 39-row visual-type guide" in error for error in markdown_errors):
raise AssertionError(f"missing visual-guide anchor was accepted: {markdown_errors}") raise AssertionError(f"missing visual-guide anchor was accepted: {markdown_errors}")
print("OK: missing visual-type guide anchor is rejected") print("OK: missing visual-type guide anchor is rejected")
+558
View File
@@ -0,0 +1,558 @@
#!/usr/bin/env python3
from __future__ import annotations
import importlib.util
import io
import sys
import tempfile
from contextlib import redirect_stdout
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VERIFIER = ROOT / "scripts/verify-polar.py"
def load_verifier():
spec = importlib.util.spec_from_file_location("verify_polar", VERIFIER)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def category(index: int, value: int, endpoint: tuple[float, float] | None) -> str:
body = '<text data-polar-value-label="">{}</text>'.format(value)
if endpoint is not None:
x, y = endpoint
body = (
f'<line data-polar-ray="" x1="100" y1="100" x2="{x}" y2="{y}" '
'stroke-width="2"/>'
f'<circle data-polar-marker="" cx="{x}" cy="{y}" r="4" '
'stroke-width="1.2"/>' + body
)
return (
f'<g data-polar-category="c{index}" data-polar-index="{index}" '
f'data-polar-value="{value}">{body}</g>'
)
def document(categories: str, extra_svg: str = "") -> str:
return (
'<!DOCTYPE html><html><body>'
'<svg data-polar-chart="" data-polar-cx="100" data-polar-cy="100" '
'data-polar-radius="100" data-polar-min="0" data-polar-max="100" '
'data-polar-start-angle="-90" data-polar-clockwise="true" '
'data-polar-radius-encoding="linear" data-polar-inner-radius="0">'
'<rect data-polar-background="" width="100%" height="100%" fill="white"/>'
'<circle data-polar-ring="" cx="100" cy="100" r="20" fill="none" '
'stroke-width="0.8"/>'
'<circle data-polar-ring="" cx="100" cy="100" r="40" fill="none" '
'stroke-width="0.8"/>'
'<circle data-polar-ring="" cx="100" cy="100" r="60" fill="none" '
'stroke-width="0.8"/>'
'<circle data-polar-ring="" cx="100" cy="100" r="80" fill="none" '
'stroke-width="0.8"/>'
'<circle data-polar-ring="" cx="100" cy="100" r="100" fill="none" '
'stroke-width="0.8"/>'
'<line data-polar-spoke="" data-polar-index="0" x1="100" y1="100" '
'x2="100" y2="0" stroke-width="0.8"/>'
'<line data-polar-spoke="" data-polar-index="1" x1="100" y1="100" '
'x2="200" y2="100" stroke-width="0.8"/>'
'<line data-polar-spoke="" data-polar-index="2" x1="100" y1="100" '
'x2="100" y2="200" stroke-width="0.8"/>'
'<line data-polar-spoke="" data-polar-index="3" x1="100" y1="100" '
'x2="0" y2="100" stroke-width="0.8"/>'
f'{extra_svg}{categories}</svg></body></html>'
)
VALID = document(
category(0, 0, None)
+ category(1, 25, (125, 100))
+ category(2, 50, (100, 150))
+ category(3, 100, (0, 100))
)
def main() -> int:
module = load_verifier()
cases = [
(
"25 percent hub",
VALID.replace('data-polar-inner-radius="0"', 'data-polar-inner-radius="25"'),
"inner radius",
),
(
"sqrt radius",
VALID.replace('x2="125"', 'x2="150"').replace('cx="125"', 'cx="150"'),
"linear endpoint",
),
(
"visible zero ray",
VALID.replace(
'<text data-polar-value-label="">0</text>',
'<line data-polar-ray="" x1="100" y1="100" x2="100" y2="100"/>'
'<text data-polar-value-label="">0</text>',
),
"zero category",
),
(
"out of range",
VALID.replace('data-polar-value="100"', 'data-polar-value="101"'),
"outside 0..100",
),
(
"duplicate index",
VALID.replace('data-polar-index="3"', 'data-polar-index="2"'),
"indices",
),
(
"quantitative wedge",
VALID.replace('</svg>', '<path data-polar-wedge="" d="M0 0"/></svg>'),
"wedge",
),
(
"unmarked quantitative wedge",
VALID.replace(
'</svg>',
'<path d="M100 100 L100 20 A80 80 0 0 1 180 100 Z"/></svg>',
),
"wedge",
),
(
"polygon area encoding",
VALID.replace(
'</svg>',
'<polygon points="100,100 100,20 180,100" fill="red"/></svg>',
),
"unsupported SVG element <polygon>",
),
(
"external referenced shape",
VALID.replace('</svg>', '<use href="#polar-wedge"/></svg>'),
"unsupported SVG element <use>",
),
(
"URL-valued ray marker",
VALID.replace(
'data-polar-ray=""',
'data-polar-ray="" marker-end="url(https://example.com/marker.svg#tip)"',
1,
),
"URL-valued SVG attribute marker-end is forbidden",
),
(
"external chart stylesheet",
VALID.replace(
'<body>',
'<head><link rel="stylesheet" href="https://example.com/chart.css"></head><body>',
),
"external stylesheets are forbidden in polar documents",
),
(
"CSS geometry override",
VALID.replace(
'<body>',
'<head><style>[data-polar-ray]{stroke-width:40!important}</style></head><body>',
),
"CSS geometry properties are forbidden in polar documents",
),
(
"CSS imported stylesheet",
VALID.replace(
'<body>',
'<head><style>@import url(https://attacker.invalid/polar.css);</style></head><body>',
),
"CSS stylesheet loading is forbidden in polar documents",
),
(
"CSS marker radius override",
VALID.replace(
'<body>',
'<head><style>circle[data-polar-marker]{r:40px}</style></head><body>',
),
"CSS geometry properties are forbidden in polar documents",
),
(
"CSS marker position override",
VALID.replace(
'<body>',
'<head><style>circle[data-polar-marker]{cx:1000px;cy:1000px}</style></head><body>',
),
"CSS geometry properties are forbidden in polar documents",
),
(
"CSS all shorthand override",
VALID.replace(
'<body>',
'<head><style>[data-polar-ray]{all:initial!important}</style></head><body>',
),
"CSS selectors must not target data-polar-chart",
),
(
"CSS individual scale override",
VALID.replace(
'<body>',
'<head><style>[data-polar-ray]{scale:10}</style></head><body>',
),
"CSS selectors must not target data-polar-chart",
),
(
"inline CSS marker radius override",
VALID.replace(
'data-polar-marker="" cx="125"',
'data-polar-marker="" style="r:40px" cx="125"',
1,
),
"inline CSS geometry properties are forbidden inside data-polar-chart",
),
(
"inline CSS marker position override",
VALID.replace(
'data-polar-marker="" cx="125"',
'data-polar-marker="" style="cx:1000px;cy:1000px" cx="125"',
1,
),
"inline CSS geometry properties are forbidden inside data-polar-chart",
),
(
"inline CSS ray translation",
VALID.replace(
'data-polar-ray=""',
'data-polar-ray="" style="translate:100px 0"',
1,
),
"inline styles are forbidden inside data-polar-chart",
),
(
"inline CSS marker offset path",
VALID.replace(
'data-polar-marker=""',
'data-polar-marker="" style="offset-path:path(&quot;M0 0L100 0&quot;);offset-distance:100%"',
1,
),
"inline styles are forbidden inside data-polar-chart",
),
(
"classed chart primitive",
VALID.replace('data-polar-marker=""', 'data-polar-marker="" class="marker"', 1),
"class attributes are forbidden inside data-polar-chart",
),
(
"duplicate ray stroke-width",
VALID.replace(
'stroke-width="2"', 'stroke-width="40" stroke-width="2"', 1
),
"duplicate attribute stroke-width on <line>",
),
(
"duplicate marker radius",
VALID.replace('r="4" stroke-width="1.2"',
'r="40" r="4" stroke-width="1.2"', 1),
"duplicate attribute r on <circle>",
),
(
"duplicate ring fill",
VALID.replace('r="20" fill="none"',
'r="20" fill="red" fill="none"', 1),
"duplicate attribute fill on <circle>",
),
(
"mismatched chart end tag",
VALID.replace(
'</svg>',
'</bogus><polygon points="100,100 100,20 180,100"/></svg>',
),
"mismatched closing tag </bogus>",
),
(
"nested SVG",
VALID.replace('</svg>', '<svg></svg></svg>'),
"unsupported SVG element <svg>",
),
(
"unmarked filled circle",
VALID.replace(
'</svg>', '<circle cx="100" cy="100" r="80" fill="red"/></svg>'
),
"circle must be a ring or category marker",
),
(
"unmarked rectangle",
VALID.replace(
'</svg>', '<rect width="50%" height="50%" fill="red"/></svg>'
),
"rect must be the chart background",
),
(
"partial chart background",
VALID.replace('data-polar-background="" width="100%"',
'data-polar-background="" width="50%"'),
"background must cover the full chart",
),
(
"filled grid ring",
VALID.replace(
'</svg>',
'<circle data-polar-ring="" cx="100" cy="100" r="80" fill="red"/></svg>',
),
"ring must use fill none",
),
(
"off-grid ring",
VALID.replace(
'</svg>',
'<circle data-polar-ring="" cx="100" cy="100" r="70" fill="none" stroke-width="0.8"/></svg>',
),
"ring radius 70 is not a 20% scale interval",
),
(
"thick grid ring",
VALID.replace(
'</svg>',
'<circle data-polar-ring="" cx="100" cy="100" r="80" fill="none" stroke-width="40"/></svg>',
),
"ring stroke-width 40 does not match expected 0.8",
),
(
"inline-style thick grid ring",
VALID.replace(
'r="80" fill="none" stroke-width="0.8"',
'r="80" fill="none" stroke-width="0.8" style="stroke-width: 40"',
),
"ring stroke-width 40 does not match expected 0.8",
),
(
"missing grid ring",
VALID.replace(
'<circle data-polar-ring="" cx="100" cy="100" r="20" fill="none" '
'stroke-width="0.8"/>',
'',
),
"grid-ring count is 4, expected five",
),
(
"duplicate grid radius",
VALID.replace('r="20" fill="none"', 'r="40" fill="none"'),
"ring radii must cover 20% through 100% intervals",
),
(
"small-radius duplicate grid rings",
VALID.replace('data-polar-radius="100"', 'data-polar-radius="1"')
.replace('r="20" fill="none"', 'r="0.5" fill="none"')
.replace('r="40" fill="none"', 'r="0.5" fill="none"')
.replace('r="60" fill="none"', 'r="0.5" fill="none"')
.replace('r="80" fill="none"', 'r="0.5" fill="none"')
.replace('r="100" fill="none"', 'r="0.5" fill="none"'),
"ring radii must cover 20% through 100% intervals",
),
(
"oversized endpoint marker",
VALID.replace('data-polar-marker="" cx="125" cy="100" r="4"',
'data-polar-marker="" cx="125" cy="100" r="40"'),
"marker radius 40 does not match expected 4",
),
(
"thick endpoint marker",
VALID.replace('cx="125" cy="100" r="4" stroke-width="1.2"',
'cx="125" cy="100" r="4" stroke-width="40"'),
"marker stroke-width 40 does not match expected 1.2",
),
(
"unmarked thick line",
VALID.replace(
'</svg>',
'<line x1="100" y1="100" x2="200" y2="100" stroke-width="80"/></svg>',
),
"line must be a spoke, category ray, or rule",
),
(
"thick category ray",
VALID.replace('data-polar-ray="" x1="100" y1="100" x2="125" y2="100" '
'stroke-width="2"',
'data-polar-ray="" x1="100" y1="100" x2="125" y2="100" '
'stroke-width="40"'),
"ray stroke-width 40 does not match expected 2",
),
(
"missing category spoke",
VALID.replace(
'<line data-polar-spoke="" data-polar-index="3" x1="100" y1="100" '
'x2="0" y2="100" stroke-width="0.8"/>',
'',
),
"spoke indices must be contiguous 0..3",
),
(
"thick chart rule",
VALID.replace(
'</svg>',
'<line data-polar-rule="" x1="0" y1="210" x2="200" y2="210" '
'stroke-width="40"/></svg>',
),
"rule stroke-width 40 does not match expected 0.8",
),
(
"duplicate chart rules",
VALID.replace(
'</svg>',
'<line data-polar-rule="" x1="0" y1="210" x2="200" y2="210" '
'stroke-width="0.8"/>'
'<line data-polar-rule="" x1="0" y1="220" x2="200" y2="220" '
'stroke-width="0.8"/></svg>',
),
"chart rule count is 2, expected at most one",
),
(
"transformed chart geometry",
VALID.replace('<g data-polar-category="c1"',
'<g transform="scale(10)" data-polar-category="c1"'),
"transforms are forbidden inside data-polar-chart",
),
(
"non-SVG chart root",
VALID.replace('<svg data-polar-chart=""', '<div data-polar-chart=""')
.replace('</svg>', '</div>'),
"data-polar-chart must be an SVG element",
),
(
"blank value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label=""> </text>',
),
"finite numeric",
),
(
"mismatched value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="">26</text>',
),
"does not match",
),
(
"hidden value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" hidden>25</text>',
),
"explicitly hidden",
),
(
"aria-hidden value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" aria-hidden="true">25</text>',
),
"explicitly hidden",
),
(
"display-none value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" style="display: none">25</text>',
),
"explicitly hidden",
),
(
"visibility-hidden value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" style="visibility: hidden">25</text>',
),
"explicitly hidden",
),
(
"opacity-zero value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" style="opacity: 0">25</text>',
),
"explicitly hidden",
),
(
"direct display-none value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" display=" NONE ">25</text>',
),
"explicitly hidden",
),
(
"direct visibility-hidden value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" visibility=" HiDdEn ">25</text>',
),
"explicitly hidden",
),
(
"direct opacity-zero value label",
VALID.replace(
'<text data-polar-value-label="">25</text>',
'<text data-polar-value-label="" opacity=" 0.0 ">25</text>',
),
"explicitly hidden",
),
]
failures: list[str] = []
with tempfile.TemporaryDirectory() as scratch:
root = Path(scratch)
valid_path = root / "valid.html"
valid_path.write_text(VALID, encoding="utf-8")
findings = module.check(valid_path)
if findings != []:
failures.append(f"valid fixture: expected no findings, got {findings}")
for label, source, expected in cases:
path = root / f"{label.replace(' ', '-')}.html"
path.write_text(source, encoding="utf-8")
findings = module.check(path)
if not findings:
failures.append(f"{label}: expected findings, got none")
elif not any(expected in finding for finding in findings):
failures.append(
f"{label}: expected a finding containing {expected!r}, got {findings}"
)
light = root / "light.html"
dark = root / "dark.html"
full = root / "full.html"
light.write_text(VALID, encoding="utf-8")
dark.write_text(VALID.replace('data-polar-value="25"', 'data-polar-value="26"'), encoding="utf-8")
full.write_text(VALID, encoding="utf-8")
findings = module.check_variants((light, dark, full))
if not any("variant data drift" in finding for finding in findings):
failures.append(f"variant data drift: expected finding, got {findings}")
first = root / "first.html"
second = root / "second.html"
first.write_text(VALID, encoding="utf-8")
second.write_text(
VALID.replace('data-polar-inner-radius="0"', 'data-polar-inner-radius="25"'),
encoding="utf-8",
)
output = io.StringIO()
with redirect_stdout(output):
exit_code = module.main([str(first), str(second)])
rendered = output.getvalue()
expected = f"FAIL {second}: inner radius must be zero"
if exit_code != 1 or expected not in rendered:
failures.append(
f"CLI path attribution: expected {expected!r}, got exit={exit_code}, output={rendered!r}"
)
if failures:
print("FAILURES:")
for failure in failures:
print(f"- {failure}")
return 1
print("All polar verifier tests passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+132 -19
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Verify that routing and browsing surfaces stay in sync with the skill. """Verify that routing and browsing surfaces stay in sync with the skill.
Eight drift classes, each of which has shipped before: Ten drift classes, each of which has shipped before:
1. The SKILL.md frontmatter description is the only text an agent sees before 1. The SKILL.md frontmatter description is the only text an agent sees before
deciding to load the skill — every visual type in the selection table must deciding to load the skill — every visual type in the selection table must
@@ -10,7 +10,7 @@ Eight drift classes, each of which has shipped before:
gallery tab must point at a file that exists. gallery tab must point at a file that exists.
3. Every concrete file named in README.md's architecture tree must exist. 3. Every concrete file named in README.md's architecture tree must exist.
4. Every relative references/*.md link in SKILL.md must resolve. 4. Every relative references/*.md link in SKILL.md must resolve.
5. Claude and Pi profile surfaces must both route to the profile reference. 5. Claude and Pi command/prompt surfaces must route to the matching reference.
6. The plugin manifests repeat the SKILL.md description verbatim. They are the 6. The plugin manifests repeat the SKILL.md description verbatim. They are the
text a user reads *before installing*, so by ADR 0004's own argument they text a user reads *before installing*, so by ADR 0004's own argument they
need every type's lexical hook too - and nothing else notices when they need every type's lexical hook too - and nothing else notices when they
@@ -19,6 +19,10 @@ Eight drift classes, each of which has shipped before:
with the package metadata instead of becoming a second hand-maintained API. with the package metadata instead of becoming a second hand-maintained API.
8. Every support path a strict skill bundler can extract from SKILL.md must be 8. Every support path a strict skill bundler can extract from SKILL.md must be
a literal file shipped inside the skill package. a literal file shipped inside the skill package.
9. Import command surfaces must route to the visual-type taxonomy instead of
hardcoding a count that becomes stale when a type is added.
10. The High-Level reproducibility checklist must agree with its canvas formula
and retain sequential numbering.
""" """
from __future__ import annotations from __future__ import annotations
@@ -34,17 +38,26 @@ SKILL = ROOT / "skills/diagram-design/SKILL.md"
GALLERY = ROOT / "skills/diagram-design/assets/index.html" GALLERY = ROOT / "skills/diagram-design/assets/index.html"
ASSET_DIR = ROOT / "skills/diagram-design/assets" ASSET_DIR = ROOT / "skills/diagram-design/assets"
README = ROOT / "README.md" README = ROOT / "README.md"
HIGH_LEVEL_REFERENCE = ROOT / "skills/diagram-design/references/type-high-level.md"
VARIANTS = ("", "-dark", "-full") VARIANTS = ("", "-dark", "-full")
VISUAL_TYPE_COUNT = 39
# Types whose selection-table name differs from its description vocabulary. # Types whose selection-table name differs from its description vocabulary.
DESCRIPTION_ALIASES = { DESCRIPTION_ALIASES = {
"bar chart": "bar", "bar chart": "bar",
"line chart": "line", "line chart": "line",
"scatter plot": "scatter", "scatter plot": "scatter",
} }
PROFILE_SURFACES = ( ROUTING_SURFACES = {
Path("commands/profile.md"), Path("commands/export-diagram.md"): "references/export.md",
Path("prompts/profile.md"), Path("commands/import-drawio.md"): "references/import-drawio.md",
) Path("commands/import-mermaid.md"): "references/import-mermaid.md",
Path("commands/profile.md"): "references/profiles.md",
Path("commands/doctor.md"): "references/doctor.md",
Path("prompts/export-diagram.md"): "references/export.md",
Path("prompts/import-mermaid.md"): "references/import-mermaid.md",
Path("prompts/profile.md"): "references/profiles.md",
Path("prompts/doctor.md"): "references/doctor.md",
}
FACTORY_MANIFEST = Path(".factory-plugin/plugin.json") FACTORY_MANIFEST = Path(".factory-plugin/plugin.json")
FACTORY_MARKETPLACE = Path(".factory-plugin/marketplace.json") FACTORY_MARKETPLACE = Path(".factory-plugin/marketplace.json")
SUPPORT_DIRECTORIES = frozenset( SUPPORT_DIRECTORIES = frozenset(
@@ -102,8 +115,10 @@ def check_description(errors: list[str]) -> None:
errors.append("SKILL.md frontmatter description is missing") errors.append("SKILL.md frontmatter description is missing")
return return
types = selection_table_types(markdown) types = selection_table_types(markdown)
if len(types) != 38: if len(types) != VISUAL_TYPE_COUNT:
errors.append(f"expected 38 visual types in the selection table; found {len(types)}") errors.append(
f"expected {VISUAL_TYPE_COUNT} visual types in the selection table; found {len(types)}"
)
for name in types: for name in types:
key = normalized(name) key = normalized(name)
key = DESCRIPTION_ALIASES.get(key, key) key = DESCRIPTION_ALIASES.get(key, key)
@@ -210,18 +225,18 @@ def check_packaged_support_references(
) )
def check_profile_surfaces(errors: list[str], root: Path) -> None: def check_routing_surfaces(errors: list[str], root: Path) -> None:
reference = root / "skills/diagram-design/references/profiles.md" for relative, reference_link in ROUTING_SURFACES.items():
if not reference.is_file(): reference = root / "skills/diagram-design" / reference_link
errors.append("profile source of truth is missing: skills/diagram-design/references/profiles.md") if not reference.is_file():
for relative in PROFILE_SURFACES: errors.append(f"routing source of truth is missing: skills/diagram-design/{reference_link}")
path = root / relative path = root / relative
if not path.is_file(): if not path.is_file():
errors.append(f"profile surface is missing: {relative.as_posix()}") errors.append(f"routing surface is missing: {relative.as_posix()}")
continue continue
if "references/profiles.md" not in path.read_text(encoding="utf-8"): if reference_link not in path.read_text(encoding="utf-8"):
errors.append( errors.append(
f"profile surface does not route to references/profiles.md: {relative.as_posix()}" f"routing surface does not route to {reference_link}: {relative.as_posix()}"
) )
@@ -270,6 +285,102 @@ def check_factory_install_surface(errors: list[str], root: Path) -> None:
) )
# A command that spells the type count out has to be edited by every PR that
# adds a type, and is the one file such a PR has no reason to open. Both import
# commands were left at 27 while the selection table moved on.
# The phrasing varies, so match the count rather than the one sentence it went
# stale in. Two forms carry it: the bare count standing in for the table
# (`one of the 27`), and a count attached to the taxonomy noun with room for
# adjectives between, in either order (`28 visual types`, `28 supported visual
# diagram types`, `28 types of visual diagrams`). Those clauses insist on that
# noun so an unrelated quantity — `accepts 2 file types` — is not rejected by a
# gate about the visual taxonomy.
#
# Every gap is whitespace-tolerant because both commands already wrap the
# sentence that carried the stale count, so a count can land just after the
# wrap. That is why the whole file is searched at once and the line is derived
# from the match offset rather than iterating lines.
#
# Word-form numerals (`Twenty-eight visual types`) are out of scope; README and
# the docstring say "numeral" so the gate does not claim more than it checks.
HARDCODED_COUNT_RE = re.compile(
r"one\s+of\s+(?:the\s+)?\d+\b"
r"|\b\d+\s+(?:[\w-]+\s+){0,2}?(?:visual|diagram)[\s-]+types?\b"
r"|\b\d+\s+types?\s+of\s+(?:[\w-]+\s+){0,2}?diagrams?\b",
re.IGNORECASE,
)
COUNT_SURFACES = (
Path("commands/import-drawio.md"),
Path("commands/import-mermaid.md"),
)
def check_type_counts(errors: list[str], root: Path) -> None:
"""No routing surface may write the visual-type count as a numeral."""
for relative in COUNT_SURFACES:
path = root / relative
if not path.is_file():
errors.append(f"type-count surface is missing: {relative.as_posix()}")
continue
text = path.read_text(encoding="utf-8")
for match in HARDCODED_COUNT_RE.finditer(text):
number = text.count("\n", 0, match.start()) + 1
phrase = " ".join(match.group(0).split())
errors.append(
f"{relative.as_posix()}:{number} hardcodes the visual-type count "
f"({phrase!r}); point at SKILL.md \u00a73 instead so adding a type "
f"cannot leave it stale"
)
def check_high_level_reference(errors: list[str], markdown: str) -> None:
width_formula = re.search(
r"^effective_w\s*=\s*(\d+)\s*-\s*right_strip_w\s*-\s*strip_margin",
markdown,
re.MULTILINE,
)
right_strip = re.search(r"^right_strip_w\s*=\s*(\d+)\s+if", markdown, re.MULTILINE)
strip_margin = re.search(r"^strip_margin\s*=\s*(\d+)\s+if", markdown, re.MULTILINE)
if not all((width_formula, right_strip, strip_margin)):
errors.append("High-Level canvas is missing the effective-width formula")
return
checklist = re.search(
r"^## 7\. Reproducibility checklist[^\n]*\n(.*?)(?=^## |\Z)",
markdown,
re.MULTILINE | re.DOTALL,
)
if checklist is None:
errors.append("High-Level reproducibility checklist is missing")
return
items = re.findall(r"^(\d+)\.\s+(.+)$", checklist.group(1), re.MULTILINE)
numbers = [int(number) for number, _ in items]
expected_numbers = list(range(1, len(numbers) + 1))
if numbers != expected_numbers:
rendered = ",".join(str(number) for number in numbers)
errors.append(
"High-Level reproducibility checklist numbering is not sequential: "
f"expected 1..{len(numbers)}, found {rendered}"
)
item_three = next((text for number, text in items if number == "3"), "")
checklist_width = re.search(r"effective_w\s*=\s*(\d+)", item_three)
expected_width = (
int(width_formula.group(1))
- int(right_strip.group(1))
- int(strip_margin.group(1))
)
if checklist_width is None:
errors.append("High-Level checklist item 3 is missing effective_w")
elif int(checklist_width.group(1)) != expected_width:
errors.append(
f"High-Level checklist item 3 has effective_w={checklist_width.group(1)}; "
f"expected {width_formula.group(1)} - {right_strip.group(1)} - "
f"{strip_margin.group(1)} = {expected_width}"
)
MANIFEST_DESCRIPTIONS = ( MANIFEST_DESCRIPTIONS = (
(Path(".claude-plugin/plugin.json"), ("description",)), (Path(".claude-plugin/plugin.json"), ("description",)),
(Path(".claude-plugin/marketplace.json"), ("description",)), (Path(".claude-plugin/marketplace.json"), ("description",)),
@@ -340,7 +451,9 @@ def main() -> int:
SKILL.read_text(encoding="utf-8"), SKILL.read_text(encoding="utf-8"),
SKILL.parent, SKILL.parent,
) )
check_profile_surfaces(errors, ROOT) check_type_counts(errors, ROOT)
check_high_level_reference(errors, HIGH_LEVEL_REFERENCE.read_text(encoding="utf-8"))
check_routing_surfaces(errors, ROOT)
if errors: if errors:
print("FAIL docs sync") print("FAIL docs sync")
for error in errors: for error in errors:
@@ -348,8 +461,8 @@ def main() -> int:
return 1 return 1
print( print(
"OK docs sync: description hooks, gallery reachability, README tree, " "OK docs sync: description hooks, gallery reachability, README tree, "
"reference links, packaged support files, profile surfaces, manifest descriptions, " "reference links, packaged support files, routing surfaces, manifest descriptions, "
"Factory install contract" "Factory install contract, type-count routing, High-Level invariants"
) )
return 0 return 0
+403
View File
@@ -0,0 +1,403 @@
#!/usr/bin/env python3
"""One-shot environment diagnostics verifier for Diagram Design.
Runs read-only checks for local readiness and exits non-zero only on hard failures
(or warnings when --strict is supplied).
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import shutil
import subprocess
import sys
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SKILL_RELATIVE = Path("skills/diagram-design/SKILL.md")
MAINTAINER_MARKERS = (
Path("CONTRIBUTING.md"),
Path(".github/workflows/ci.yml"),
Path("scripts/verify-plugin-package.py"),
)
EXPECTED_SCRIPTS = (
Path("scripts/verify-drawio-import.py"),
Path("scripts/verify-mermaid-import.py"),
Path("scripts/verify-motion.py"),
Path("scripts/lint-skin.py"),
Path("scripts/verify-docs-sync.py"),
)
ROUTING_SURFACES = {
Path("commands/export-diagram.md"): "references/export.md",
Path("commands/import-drawio.md"): "references/import-drawio.md",
Path("commands/import-mermaid.md"): "references/import-mermaid.md",
Path("commands/profile.md"): "references/profiles.md",
Path("commands/doctor.md"): "references/doctor.md",
Path("prompts/export-diagram.md"): "references/export.md",
Path("prompts/import-mermaid.md"): "references/import-mermaid.md",
Path("prompts/profile.md"): "references/profiles.md",
Path("prompts/doctor.md"): "references/doctor.md",
}
PASS = "pass"
WARN = "warn"
FAIL = "fail"
@dataclass
class CheckResult:
name: str
status: str
message: str
fix: str | None = None
def run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
def probe_python_command() -> tuple[str | None, str | None]:
"""Resolve python3 first, then python."""
for candidate in ("python3", "python"):
executable = shutil.which(candidate)
if executable:
return candidate, executable
return None, None
def check_python_runtime() -> tuple[CheckResult, str | None]:
command_name, executable = probe_python_command()
if command_name is None or executable is None:
return (
CheckResult(
name="Python runtime",
status=FAIL,
message="No python3 or python command was found on PATH.",
fix="Install Python 3.10+ and ensure python3 or python is available on PATH.",
),
None,
)
version_probe = run_command(
[command_name, "-c", "import sys; print('.'.join(str(p) for p in sys.version_info[:3]))"]
)
if version_probe.returncode != 0:
detail = version_probe.stderr.strip() or "version probe failed"
return (
CheckResult(
name="Python runtime",
status=FAIL,
message=f"Could not query version via {command_name}: {detail}",
fix="Ensure the selected Python command is executable and not blocked by shell aliases.",
),
command_name,
)
version_text = version_probe.stdout.strip()
try:
major, minor, patch = (int(part) for part in version_text.split(".", 2))
except ValueError:
return (
CheckResult(
name="Python runtime",
status=FAIL,
message=f"Unexpected Python version format from {command_name}: {version_text!r}",
fix="Use a standard CPython installation with semantic version output.",
),
command_name,
)
if (major, minor) < (3, 10):
return (
CheckResult(
name="Python runtime",
status=FAIL,
message=(
f"Python {major}.{minor}.{patch} found via {command_name} at {executable}; "
"Diagram Design requires Python >= 3.10."
),
fix="Upgrade Python to 3.10+ and re-run /diagram-design:doctor.",
),
command_name,
)
return (
CheckResult(
name="Python runtime",
status=PASS,
message=f"Python {major}.{minor}.{patch} found via {command_name} at {executable}.",
),
command_name,
)
def check_playwright(python_cmd: str | None) -> CheckResult:
if python_cmd is None:
return CheckResult(
name="Playwright PNG export readiness",
status=FAIL,
message="Playwright check skipped because no Python command was available.",
fix="Resolve Python first; then install with: pip install playwright && playwright install chromium",
)
import_probe = run_command([python_cmd, "-c", "import playwright; print(playwright.__version__)"])
if import_probe.returncode != 0:
return CheckResult(
name="Playwright PNG export readiness",
status=WARN,
message="Playwright package is not available in the active Python interpreter.",
fix="pip install playwright && playwright install chromium",
)
chromium_probe = run_command(
[
python_cmd,
"-c",
(
"from pathlib import Path\n"
"from playwright.sync_api import sync_playwright\n"
"with sync_playwright() as p:\n"
" path = Path(p.chromium.executable_path)\n"
" print(path)\n"
" raise SystemExit(0 if path.is_file() else 3)\n"
),
]
)
if chromium_probe.returncode == 0:
return CheckResult(
name="Playwright PNG export readiness",
status=PASS,
message=f"Playwright is installed and Chromium is present at {chromium_probe.stdout.strip()}.",
)
detail = chromium_probe.stderr.strip() or "Chromium executable was not detected"
return CheckResult(
name="Playwright PNG export readiness",
status=WARN,
message=f"Playwright is installed but Chromium is not ready: {detail}",
fix="pip install playwright && playwright install chromium",
)
def is_maintainer_checkout(root: Path) -> bool:
"""Return whether root is a source checkout with maintainer-only surfaces."""
return all((root / marker).is_file() for marker in MAINTAINER_MARKERS)
def resolve_skill_file(root: Path) -> Path | None:
"""Find SKILL.md in either a repository/plugin root or standalone skill root."""
repository_skill = root / SKILL_RELATIVE
if repository_skill.is_file():
return repository_skill
standalone_skill = root / "SKILL.md"
if standalone_skill.is_file():
return standalone_skill
return None
def check_expected_scripts(root: Path) -> CheckResult:
if not is_maintainer_checkout(root):
return CheckResult(
name="Expected script presence",
status=PASS,
message="Installed-skill mode detected; maintainer-only repository scripts are not required.",
)
missing = [path.as_posix() for path in EXPECTED_SCRIPTS if not (root / path).is_file()]
if missing:
return CheckResult(
name="Expected script presence",
status=FAIL,
message="Missing required script(s): " + ", ".join(missing),
fix="Restore missing scripts from the repository root and re-run diagnostics.",
)
return CheckResult(
name="Expected script presence",
status=PASS,
message=f"All required scripts are present ({len(EXPECTED_SCRIPTS)} checked).",
)
def check_routing_surfaces(root: Path) -> CheckResult:
if not is_maintainer_checkout(root):
return CheckResult(
name="Plugin wiring surfaces",
status=PASS,
message="Installed-skill mode detected; maintainer command/prompt wiring is not required.",
)
missing_files: list[str] = []
mismatched: list[str] = []
for relative_path, reference in ROUTING_SURFACES.items():
path = root / relative_path
if not path.is_file():
missing_files.append(relative_path.as_posix())
continue
source = path.read_text(encoding="utf-8")
if reference not in source:
mismatched.append(f"{relative_path.as_posix()} -> {reference}")
if missing_files or mismatched:
issues: list[str] = []
if missing_files:
issues.append("missing files: " + ", ".join(missing_files))
if mismatched:
issues.append("reference mismatches: " + ", ".join(mismatched))
return CheckResult(
name="Plugin wiring surfaces",
status=FAIL,
message="; ".join(issues),
fix="Ensure each command/prompt file exists and routes to the matching references/*.md file.",
)
return CheckResult(
name="Plugin wiring surfaces",
status=PASS,
message=f"All command/prompt routing surfaces are present and wired ({len(ROUTING_SURFACES)} checked).",
)
def check_common_path_mistakes(root: Path, cwd: Path) -> CheckResult:
warnings: list[str] = []
fixes: list[str] = []
if resolve_skill_file(root) is None:
warnings.append(
"Diagram Design SKILL.md was not found under the resolved installation root."
)
fixes.append("Reinstall or update Diagram Design, then run the doctor again.")
if platform.system().lower().startswith("win") and " " in str(cwd):
warnings.append(
"Path contains spaces on Windows; unquoted shell examples can fail for import/export commands."
)
fixes.append('Use quoted paths, e.g. "C:/path with spaces/diagram.html".')
if not warnings:
return CheckResult(
name="Common path mistakes",
status=PASS,
message="No common path mistakes were detected.",
)
return CheckResult(
name="Common path mistakes",
status=WARN,
message=" ".join(warnings),
fix=" ".join(fixes),
)
def summarize(checks: list[CheckResult], strict: bool) -> tuple[str, dict[str, int], int]:
counts = {PASS: 0, WARN: 0, FAIL: 0}
for check in checks:
counts[check.status] += 1
if counts[FAIL] > 0:
status = "FAIL"
elif counts[WARN] > 0:
status = "WARN"
else:
status = "PASS"
exit_code = 0
if counts[FAIL] > 0:
exit_code = 1
elif strict and counts[WARN] > 0:
exit_code = 1
return status, counts, exit_code
def print_report(checks: list[CheckResult], strict: bool, emit_json: bool) -> int:
status, counts, exit_code = summarize(checks, strict)
print(
f"Doctor summary: {status} ({counts[PASS]} pass, {counts[WARN]} warn, {counts[FAIL]} fail)"
)
markers = {PASS: "PASS", WARN: "WARN", FAIL: "FAIL"}
for check in checks:
print(f"[{markers[check.status]}] {check.name}: {check.message}")
needs_next_actions = counts[WARN] > 0 or counts[FAIL] > 0
if needs_next_actions:
print("\nNext actions")
for check in checks:
if check.status in (WARN, FAIL) and check.fix:
print(f"- {check.fix}")
if emit_json:
payload = {
"status": status,
"counts": counts,
"checks": [asdict(check) for check in checks],
"timestamp": datetime.now(timezone.utc).isoformat(),
"strict": strict,
"platform": {
"system": platform.system(),
"release": platform.release(),
"python": sys.version.split()[0],
"cwd": str(Path.cwd()),
"root": str(ROOT),
},
}
print(json.dumps(payload, indent=2))
return exit_code
def run_doctor(root: Path, cwd: Path, strict: bool, emit_json: bool) -> int:
checks: list[CheckResult] = []
python_check, python_cmd = check_python_runtime()
checks.append(python_check)
checks.append(check_playwright(python_cmd))
checks.append(check_expected_scripts(root))
checks.append(check_routing_surfaces(root))
checks.append(check_common_path_mistakes(root, cwd))
return print_report(checks, strict, emit_json)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run one-shot Diagram Design environment diagnostics."
)
parser.add_argument(
"--strict",
action="store_true",
help="Treat warnings as failures.",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit machine-readable JSON report after the human summary.",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv)
root = ROOT
cwd = Path.cwd()
return run_doctor(root, cwd, args.strict, args.json)
if __name__ == "__main__":
raise SystemExit(main())
+802
View File
@@ -0,0 +1,802 @@
#!/usr/bin/env python3
"""Verify the machine-readable contract for quantitative polar diagrams."""
from __future__ import annotations
from dataclasses import dataclass
from html.parser import HTMLParser
from math import cos, hypot, isfinite, pi, radians, sin
from pathlib import Path
import re
import sys
from typing import Sequence
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parent.parent
ASSET_DIR = ROOT / "skills" / "diagram-design" / "assets"
DEFAULT_PATHS = (
ASSET_DIR / "example-polar.html",
ASSET_DIR / "example-polar-dark.html",
ASSET_DIR / "example-polar-full.html",
)
TOLERANCE = 0.75
ALLOWED_CHART_ELEMENTS = frozenset({"svg", "g", "line", "circle", "rect", "text", "title", "desc"})
VOID_ELEMENTS = frozenset({
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
"param", "source", "track", "wbr",
})
CSS_GEOMETRY_PROPERTY = re.compile(
r"(?:^|[;{])\s*(?:fill|stroke-width|transform|filter|clip-path|mask|"
r"marker-(?:start|mid|end)|r|cx|cy|x|y|x1|y1|x2|y2|rx|ry|d|points)\s*:",
re.IGNORECASE,
)
CSS_STYLESHEET_LOADING = re.compile(r"@import\b|url\s*\(", re.IGNORECASE)
REFERENCE_ATTRIBUTES = frozenset({
"href", "xlink:href", "filter", "clip-path", "mask",
"marker-start", "marker-mid", "marker-end",
})
SAFE_HTML_STYLE_TARGETS = frozenset({
"html", "body", "div", "main", "header", "footer", "section", "article",
"p", "h1", "h2", "h3", "h4", "h5", "h6",
})
CSS_RULE = re.compile(r"([^{}]+)\{([^{}]*)\}")
CSS_COMBINATOR = re.compile(r"\s+|[>+~]")
@dataclass
class ValueLabel:
attrs: dict[str, str]
text: str
@dataclass
class Category:
name: str
index: int
value: float
focal: bool
rays: list[dict[str, str]]
markers: list[dict[str, str]]
value_labels: list[ValueLabel]
@dataclass
class Chart:
attrs: dict[str, str]
categories: list[Category]
tag: str
backgrounds: list[dict[str, str]]
rings: list[dict[str, str]]
spokes: list[dict[str, str]]
rules: list[dict[str, str]]
unsupported_elements: list[str]
duplicate_attributes: list[tuple[str, str]]
url_attributes: list[str]
unmarked_circle_count: int
unmarked_line_count: int
unmarked_rect_count: int
transformed_element_count: int
css_geometry_count: int
inline_style_count: int
class_attribute_count: int
def _float(value: str | None) -> float:
try:
parsed = float(value) if value is not None else float("nan")
except (TypeError, ValueError):
return float("nan")
return parsed if isfinite(parsed) else float("nan")
def _int(value: str | None) -> int:
try:
return int(value) if value is not None else -1
except (TypeError, ValueError):
return -1
def _geometry_tolerance(radius: float | None) -> float:
if radius is None or not isfinite(radius) or radius <= 0:
return TOLERANCE
return min(TOLERANCE, radius / 20)
def _contains_geometry_css(css: str) -> bool:
without_comments = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL)
return "\\" in without_comments or CSS_GEOMETRY_PROPERTY.search(without_comments) is not None
def _contains_stylesheet_loading(css: str) -> bool:
without_comments = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL)
return CSS_STYLESHEET_LOADING.search(without_comments) is not None
def _canonical_svg_layout(declarations: str) -> bool:
parsed: dict[str, str] = {}
for declaration in declarations.split(";"):
if not declaration.strip():
continue
if ":" not in declaration:
return False
name, value = declaration.split(":", 1)
key = name.strip().casefold()
if key in parsed:
return False
parsed[key] = value.strip().casefold()
return parsed == {"width": "100%", "min-width": "900px", "display": "block"}
def _selector_can_target_chart(selector: str) -> bool:
selector = selector.strip()
if not selector or "[" in selector or "#" in selector or "\\" in selector:
return True
if selector == ":root":
return False
target = CSS_COMBINATOR.split(selector)[-1]
target = target.split(":", 1)[0]
if target.startswith("."):
return False
return target.casefold() not in SAFE_HTML_STYLE_TARGETS
def _stylesheet_can_target_chart(css: str) -> bool:
without_comments = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL)
if "\\" in without_comments:
return True
for prelude, declarations in CSS_RULE.findall(without_comments):
prelude = prelude.strip()
if prelude.startswith("@"):
continue
for selector in prelude.split(","):
if selector.strip() == "svg" and _canonical_svg_layout(declarations):
continue
if _selector_can_target_chart(selector):
return True
return False
def _allowed_stylesheet(href: str) -> bool:
parsed = urlsplit(href.strip())
return (
parsed.scheme == "https"
and parsed.hostname == "fonts.googleapis.com"
and parsed.path == "/css2"
)
def _style_property(attrs: dict[str, str], property_name: str) -> str | None:
result: str | None = None
for declaration in attrs.get("style", "").split(";"):
if ":" not in declaration:
continue
name, raw_value = declaration.split(":", 1)
if name.strip().casefold() == property_name:
result = raw_value.split("!", 1)[0].strip().casefold()
return result
def _presentation_property(attrs: dict[str, str], property_name: str) -> str:
style_value = _style_property(attrs, property_name)
if style_value is not None:
return style_value
return attrs.get(property_name, "").strip().casefold()
class PolarParser(HTMLParser):
"""Capture charts and the geometry-bearing descendants of each category."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.charts: list[Chart] = []
self.parse_findings: list[str] = []
self.style_blocks: list[str] = []
self.stylesheet_urls: list[str] = []
self._stack: list[str] = []
self._style_stack: list[tuple[int, int]] = []
self._chart_depth: int | None = None
self._chart: Chart | None = None
self._category_stack: list[tuple[int, Category]] = []
self._value_label_stack: list[tuple[int, ValueLabel]] = []
def _in_chart(self) -> bool:
return self._chart is not None and self._chart_depth is not None
def _current_category(self) -> Category | None:
if not self._category_stack:
return None
return self._category_stack[-1][1]
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.casefold()
data: dict[str, str] = {}
duplicate_attributes: list[str] = []
for raw_key, raw_value in attrs:
key = raw_key.casefold()
if key in data:
duplicate_attributes.append(key)
else:
data[key] = raw_value or ""
depth = len(self._stack)
if tag == "style":
self.style_blocks.append("")
self._style_stack.append((depth, len(self.style_blocks) - 1))
if tag == "link" and "stylesheet" in data.get("rel", "").casefold().split():
self.stylesheet_urls.append(data.get("href", ""))
if "data-polar-chart" in data:
chart = Chart(
attrs=dict(data),
categories=[],
tag=tag,
backgrounds=[],
rings=[],
spokes=[],
rules=[],
unsupported_elements=[],
duplicate_attributes=[],
url_attributes=[],
unmarked_circle_count=0,
unmarked_line_count=0,
unmarked_rect_count=0,
transformed_element_count=0,
css_geometry_count=0,
inline_style_count=0,
class_attribute_count=0,
)
chart.duplicate_attributes.extend((tag, key) for key in duplicate_attributes)
self.charts.append(chart)
if self._chart is None:
self._chart = chart
self._chart_depth = depth
if self._in_chart():
assert self._chart is not None
assert self._chart_depth is not None
is_chart_root = depth == self._chart_depth
if not is_chart_root:
self._chart.duplicate_attributes.extend(
(tag, key) for key in duplicate_attributes
)
if tag not in ALLOWED_CHART_ELEMENTS or (tag == "svg" and not is_chart_root):
self._chart.unsupported_elements.append(tag)
if "transform" in data or _style_property(data, "transform") is not None:
self._chart.transformed_element_count += 1
if _contains_geometry_css(data.get("style", "")):
self._chart.css_geometry_count += 1
if "style" in data:
self._chart.inline_style_count += 1
if "class" in data:
self._chart.class_attribute_count += 1
for key, value in data.items():
normalized_value = value.strip().casefold()
if key in REFERENCE_ATTRIBUTES and normalized_value not in {"", "none"}:
self._chart.url_attributes.append(key)
elif key in {"fill", "stroke"} and "url(" in normalized_value:
self._chart.url_attributes.append(key)
if tag == "g" and "data-polar-category" in data:
category = Category(
name=data.get("data-polar-category", ""),
index=_int(data.get("data-polar-index")),
value=_float(data.get("data-polar-value")),
focal=data.get("data-polar-focal", "").casefold() == "true",
rays=[],
markers=[],
value_labels=[],
)
self._chart.categories.append(category)
self._category_stack.append((depth, category))
current = self._current_category()
if current is not None:
if tag == "text" and "data-polar-value-label" in data:
label = ValueLabel(attrs=data, text="")
current.value_labels.append(label)
self._value_label_stack.append((depth, label))
if tag == "line":
is_ray = "data-polar-ray" in data
is_spoke = "data-polar-spoke" in data
is_rule = "data-polar-rule" in data
if is_ray and not is_spoke and not is_rule and current is not None:
current.rays.append(data)
elif is_spoke and not is_ray and not is_rule:
self._chart.spokes.append(data)
elif is_rule and not is_ray and not is_spoke:
self._chart.rules.append(data)
else:
self._chart.unmarked_line_count += 1
elif tag == "circle":
is_marker = "data-polar-marker" in data
is_ring = "data-polar-ring" in data
if is_marker and not is_ring and current is not None:
current.markers.append(data)
elif is_ring and not is_marker:
self._chart.rings.append(data)
else:
self._chart.unmarked_circle_count += 1
elif tag == "rect":
if "data-polar-background" in data:
self._chart.backgrounds.append(data)
else:
self._chart.unmarked_rect_count += 1
if tag not in VOID_ELEMENTS:
self._stack.append(tag)
def handle_data(self, data: str) -> None:
if self._style_stack:
style_index = self._style_stack[-1][1]
self.style_blocks[style_index] += data
if self._value_label_stack:
self._value_label_stack[-1][1].text += data
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self.handle_starttag(tag, attrs)
self.handle_endtag(tag)
def handle_endtag(self, tag: str) -> None:
tag = tag.casefold()
if tag in VOID_ELEMENTS:
return
if not self._stack or self._stack[-1] != tag:
if self._in_chart():
self.parse_findings.append(f"mismatched closing tag </{tag}>")
return
self._stack.pop()
depth = len(self._stack)
while self._style_stack and self._style_stack[-1][0] >= depth:
self._style_stack.pop()
while self._value_label_stack and self._value_label_stack[-1][0] >= depth:
self._value_label_stack.pop()
while self._category_stack and self._category_stack[-1][0] >= depth:
self._category_stack.pop()
if self._chart_depth is not None and depth <= self._chart_depth:
self._chart = None
self._chart_depth = None
self._category_stack.clear()
self._value_label_stack.clear()
def close(self) -> None:
super().close()
if self._chart is not None:
self.parse_findings.append("unterminated data-polar-chart element")
def _parse(path: Path) -> PolarParser:
parser = PolarParser()
parser.feed(path.read_text(encoding="utf-8"))
parser.close()
return parser
def _point(attrs: dict[str, str], x_key: str, y_key: str) -> tuple[float, float] | None:
x = _float(attrs.get(x_key))
y = _float(attrs.get(y_key))
if not isfinite(x) or not isfinite(y):
return None
return x, y
def _explicitly_hidden(attrs: dict[str, str]) -> bool:
if "hidden" in attrs or attrs.get("aria-hidden", "").strip().casefold() == "true":
return True
if attrs.get("display", "").strip().casefold() == "none":
return True
if attrs.get("visibility", "").strip().casefold() == "hidden":
return True
if "opacity" in attrs and _float(attrs["opacity"].strip()) == 0:
return True
for declaration in attrs.get("style", "").split(";"):
if ":" not in declaration:
continue
name, raw_value = declaration.split(":", 1)
name = name.strip().casefold()
value = raw_value.split("!", 1)[0].strip().casefold()
if (name == "display" and value == "none") or (
name == "visibility" and value == "hidden"
):
return True
if name == "opacity" and _float(value) == 0:
return True
return False
def check(path: Path) -> list[str]:
"""Return all contract violations found in one HTML document."""
parser = _parse(path)
findings: list[str] = []
if not parser.charts:
return ["missing data-polar-chart"]
if len(parser.charts) != 1:
findings.append(f"duplicate data-polar-chart elements ({len(parser.charts)})")
chart = parser.charts[0]
attrs = chart.attrs
findings.extend(parser.parse_findings)
if any(_contains_stylesheet_loading(css) for css in parser.style_blocks):
findings.append("CSS stylesheet loading is forbidden in polar documents")
if any(_contains_geometry_css(css) for css in parser.style_blocks):
findings.append("CSS geometry properties are forbidden in polar documents")
if any(_stylesheet_can_target_chart(css) for css in parser.style_blocks):
findings.append("CSS selectors must not target data-polar-chart")
if any(not _allowed_stylesheet(href) for href in parser.stylesheet_urls):
findings.append("external stylesheets are forbidden in polar documents")
for tag, key in chart.duplicate_attributes:
findings.append(f"duplicate attribute {key} on <{tag}>")
for key in sorted(set(chart.url_attributes)):
findings.append(f"URL-valued SVG attribute {key} is forbidden")
if chart.css_geometry_count:
findings.append(
"inline CSS geometry properties are forbidden inside data-polar-chart "
f"({chart.css_geometry_count})"
)
if chart.inline_style_count:
findings.append(
"inline styles are forbidden inside data-polar-chart "
f"({chart.inline_style_count})"
)
if chart.class_attribute_count:
findings.append(
"class attributes are forbidden inside data-polar-chart "
f"({chart.class_attribute_count})"
)
if chart.tag != "svg":
findings.append("data-polar-chart must be an SVG element")
for tag in sorted(set(chart.unsupported_elements)):
count = chart.unsupported_elements.count(tag)
findings.append(
f"unsupported SVG element <{tag}> can create forbidden wedge or area encoding ({count})"
)
if chart.unmarked_circle_count:
findings.append(
"circle must be a ring or category marker "
f"({chart.unmarked_circle_count} unmarked)"
)
if chart.unmarked_line_count:
findings.append(
"line must be a spoke, category ray, or rule "
f"({chart.unmarked_line_count} unmarked)"
)
if chart.unmarked_rect_count:
findings.append(
f"rect must be the chart background ({chart.unmarked_rect_count} unmarked)"
)
if chart.transformed_element_count:
findings.append(
"transforms are forbidden inside data-polar-chart "
f"({chart.transformed_element_count})"
)
if len(chart.backgrounds) > 1:
findings.append(f"chart background count is {len(chart.backgrounds)}, expected at most one")
for background in chart.backgrounds:
x = _float(background.get("x", "0"))
y = _float(background.get("y", "0"))
if (
background.get("width", "").strip() != "100%"
or background.get("height", "").strip() != "100%"
or x != 0
or y != 0
):
findings.append("background must cover the full chart")
if len(chart.rules) > 1:
findings.append(
f"chart rule count is {len(chart.rules)}, expected at most one"
)
for rule in chart.rules:
stroke_width = _float(_presentation_property(rule, "stroke-width"))
if stroke_width != 0.8:
findings.append(
f"rule stroke-width {stroke_width:g} does not match expected 0.8"
)
required = (
"data-polar-cx",
"data-polar-cy",
"data-polar-radius",
"data-polar-min",
"data-polar-max",
"data-polar-start-angle",
"data-polar-clockwise",
"data-polar-radius-encoding",
"data-polar-inner-radius",
)
numeric_keys = {
"data-polar-cx",
"data-polar-cy",
"data-polar-radius",
"data-polar-min",
"data-polar-max",
"data-polar-start-angle",
"data-polar-inner-radius",
}
metadata: dict[str, float | bool] = {}
for key in required:
raw = attrs.get(key)
if raw is None or raw == "":
findings.append(f"missing metadata {key}")
continue
if key in numeric_keys:
parsed = _float(raw)
if not isfinite(parsed):
findings.append(f"non-numeric metadata {key}")
else:
metadata[key] = parsed
elif key == "data-polar-clockwise":
value = raw.casefold()
if value not in {"true", "false"}:
findings.append(f"non-numeric metadata {key}")
else:
metadata[key] = value == "true"
minimum = metadata.get("data-polar-min")
maximum = metadata.get("data-polar-max")
radius = metadata.get("data-polar-radius")
inner_radius = metadata.get("data-polar-inner-radius")
cx = metadata.get("data-polar-cx")
cy = metadata.get("data-polar-cy")
start_angle = metadata.get("data-polar-start-angle")
clockwise = metadata.get("data-polar-clockwise")
geometry_tolerance = _geometry_tolerance(radius if isinstance(radius, float) else None)
if isinstance(minimum, float) and minimum != 0:
findings.append(f"minimum must be zero (got {minimum:g})")
if isinstance(maximum, float) and maximum <= 0:
findings.append(f"maximum must be positive (got {maximum:g})")
if isinstance(radius, float) and radius <= 0:
findings.append(f"radius must be positive (got {radius:g})")
if attrs.get("data-polar-radius-encoding") not in {None, "linear"}:
findings.append("non-linear radius encoding")
if isinstance(inner_radius, float) and inner_radius != 0:
findings.append(f"inner radius must be zero (got {inner_radius:g})")
if len(chart.rings) != 5:
findings.append(f"grid-ring count is {len(chart.rings)}, expected five")
for ring in chart.rings:
ring_radius = _float(ring.get("r"))
if _presentation_property(ring, "fill") != "none":
findings.append("ring must use fill none")
ring_center = _point(ring, "cx", "cy")
if (
ring_center is None
or not isinstance(cx, float)
or not isinstance(cy, float)
or hypot(ring_center[0] - cx, ring_center[1] - cy) > geometry_tolerance
):
findings.append("ring center must match the polar chart center")
if isinstance(radius, float) and radius > 0 and isfinite(ring_radius):
expected_radii = [radius * step / 5 for step in range(1, 6)]
if min(abs(ring_radius - expected) for expected in expected_radii) > geometry_tolerance:
findings.append(
f"ring radius {ring_radius:g} is not a 20% scale interval"
)
elif not isfinite(ring_radius):
findings.append("ring radius must be finite numeric")
stroke_width = _float(_presentation_property(ring, "stroke-width"))
if stroke_width != 0.8:
findings.append(
f"ring stroke-width {stroke_width:g} does not match expected 0.8"
)
if isinstance(radius, float) and radius > 0 and len(chart.rings) == 5:
observed_radii = sorted(_float(ring.get("r")) for ring in chart.rings)
expected_radii = [radius * step / 5 for step in range(1, 6)]
if any(
not isfinite(observed) or abs(observed - expected) > geometry_tolerance
for observed, expected in zip(observed_radii, expected_radii)
):
findings.append("ring radii must cover 20% through 100% intervals")
count = len(chart.categories)
if not 4 <= count <= 8:
findings.append(f"category count {count} outside 4..8")
indices = [category.index for category in chart.categories]
if sorted(indices) != list(range(count)):
findings.append(f"indices must be contiguous 0..{count - 1}")
names = [category.name.strip() for category in chart.categories]
if len(names) != len(set(names)):
findings.append("duplicate trimmed category names")
if sum(category.focal for category in chart.categories) > 1:
findings.append("more than one focal category")
geometry_ready = (
isinstance(cx, float)
and isinstance(cy, float)
and isinstance(radius, float)
and isinstance(maximum, float)
and isinstance(start_angle, float)
and isinstance(clockwise, bool)
and radius > 0
and maximum > 0
and count > 0
)
spoke_indices = [_int(spoke.get("data-polar-index")) for spoke in chart.spokes]
if sorted(spoke_indices) != list(range(count)):
findings.append(f"spoke indices must be contiguous 0..{count - 1}")
for spoke, spoke_index in zip(chart.spokes, spoke_indices):
stroke_width = _float(_presentation_property(spoke, "stroke-width"))
if stroke_width != 0.8:
findings.append(
f"spoke {spoke_index} stroke-width {stroke_width:g} "
"does not match expected 0.8"
)
if not geometry_ready or not 0 <= spoke_index < count:
continue
assert isinstance(cx, float)
assert isinstance(cy, float)
assert isinstance(radius, float)
assert isinstance(start_angle, float)
assert isinstance(clockwise, bool)
expected_angle = radians(start_angle) + (1 if clockwise else -1) * 2 * pi * spoke_index / count
expected_x = cx + radius * cos(expected_angle)
expected_y = cy + radius * sin(expected_angle)
spoke_start = _point(spoke, "x1", "y1")
spoke_end = _point(spoke, "x2", "y2")
if spoke_start is None or hypot(spoke_start[0] - cx, spoke_start[1] - cy) > geometry_tolerance:
findings.append(f"spoke {spoke_index} must start at chart center")
if spoke_end is None or hypot(spoke_end[0] - expected_x, spoke_end[1] - expected_y) > geometry_tolerance:
findings.append(f"spoke {spoke_index} endpoint outside tolerance")
for category in chart.categories:
value = category.value
ray = category.rays[0] if len(category.rays) == 1 else None
if ray is not None:
ray_stroke_width = _float(_presentation_property(ray, "stroke-width"))
expected_ray_stroke_width = 2.4 if category.focal else 2.0
if ray_stroke_width != expected_ray_stroke_width:
findings.append(
f"category {category.name!r} ray stroke-width {ray_stroke_width:g} "
f"does not match expected {expected_ray_stroke_width:g}"
)
marker = category.markers[0] if len(category.markers) == 1 else None
if marker is not None:
marker_radius = _float(marker.get("r"))
expected_marker_radius = 5.0 if category.focal else 4.0
if marker_radius != expected_marker_radius:
findings.append(
f"category {category.name!r} marker radius {marker_radius:g} "
f"does not match expected {expected_marker_radius:g}"
)
marker_stroke_width = _float(_presentation_property(marker, "stroke-width"))
if marker_stroke_width != 1.2:
findings.append(
f"category {category.name!r} marker stroke-width {marker_stroke_width:g} "
"does not match expected 1.2"
)
if len(category.value_labels) != 1:
findings.append(
f"category {category.name!r} value-label count is {len(category.value_labels)}, expected one"
)
else:
label = category.value_labels[0]
label_value = _float(label.text.strip())
if _explicitly_hidden(label.attrs):
findings.append(f"category {category.name!r} value label is explicitly hidden")
if not isfinite(label_value):
findings.append(f"category {category.name!r} value label must be finite numeric")
elif isfinite(value) and label_value != value:
findings.append(
f"category {category.name!r} value label {label_value:g} "
f"does not match data-polar-value {value:g}"
)
if not isfinite(value):
findings.append(f"non-numeric category value for {category.name!r}")
continue
if isinstance(minimum, float) and isinstance(maximum, float) and not minimum <= value <= maximum:
findings.append(f"value {value:g} outside {minimum:g}..{maximum:g}")
if value == 0:
if category.rays or category.markers:
findings.append(f"zero category {category.name!r} must not carry a ray or marker")
elif value > 0:
if len(category.rays) != 1 or len(category.markers) != 1:
findings.append(
f"positive category {category.name!r} must carry exactly one ray and marker"
)
if not geometry_ready or value <= 0 or len(category.rays) != 1:
continue
assert isinstance(cx, float)
assert isinstance(cy, float)
assert isinstance(radius, float)
assert isinstance(maximum, float)
assert isinstance(start_angle, float)
assert isinstance(clockwise, bool)
expected_angle = radians(start_angle) + (1 if clockwise else -1) * 2 * pi * category.index / count
expected_radius = radius * value / maximum
expected_x = cx + expected_radius * cos(expected_angle)
expected_y = cy + expected_radius * sin(expected_angle)
assert ray is not None
ray_start = _point(ray, "x1", "y1")
ray_end = _point(ray, "x2", "y2")
if ray_start is None:
findings.append(f"ray geometry non-numeric for {category.name!r}")
elif hypot(ray_start[0] - cx, ray_start[1] - cy) > geometry_tolerance:
findings.append(f"ray start not at center for {category.name!r}")
if ray_end is None:
findings.append(f"ray endpoint non-numeric for {category.name!r}")
elif hypot(ray_end[0] - expected_x, ray_end[1] - expected_y) > geometry_tolerance:
findings.append(
f"linear endpoint outside tolerance for {category.name!r}: "
f"expected ({expected_x:g},{expected_y:g}), got ({ray_end[0]:g},{ray_end[1]:g})"
)
marker_end = _point(marker, "cx", "cy") if marker is not None else None
if marker_end is None:
if marker is not None:
findings.append(f"marker endpoint non-numeric for {category.name!r}")
elif hypot(marker_end[0] - expected_x, marker_end[1] - expected_y) > geometry_tolerance:
findings.append(f"marker endpoint outside tolerance for {category.name!r}")
return findings
def _signature(path: Path) -> tuple[object, ...] | None:
parser = _parse(path)
if len(parser.charts) != 1:
return None
chart = parser.charts[0]
attrs = chart.attrs
values = [_float(attrs.get(key)) for key in (
"data-polar-cx",
"data-polar-cy",
"data-polar-radius",
"data-polar-min",
"data-polar-max",
"data-polar-start-angle",
)]
clockwise = attrs.get("data-polar-clockwise", "").casefold() == "true"
if any(not isfinite(value) for value in values):
return None
cx, cy, radius, minimum, maximum, start_angle = values
return (
cx,
cy,
radius,
minimum,
maximum,
start_angle,
clockwise,
tuple(
(category.index, category.name, category.value, category.focal)
for category in sorted(chart.categories, key=lambda category: category.index)
),
)
def _check_variants_with_paths(paths: Sequence[Path]) -> list[tuple[Path, str]]:
"""Return variant findings while retaining the file that owns each finding."""
findings: list[tuple[Path, str]] = []
signatures: list[tuple[object, ...] | None] = []
for path in paths:
if not path.exists():
findings.append((path, f"file not found: {path}"))
signatures.append(None)
continue
findings.extend((path, finding) for finding in check(path))
signatures.append(_signature(path))
present = [signature for signature in signatures if signature is not None]
if len(present) >= 2 and any(signature != present[0] for signature in present[1:]):
findings.append((paths[0], "variant data drift"))
return findings
def check_variants(paths: Sequence[Path]) -> list[str]:
"""Check each variant and ensure all variants carry one shared dataset."""
return [finding for _path, finding in _check_variants_with_paths(paths)]
def main(argv: Sequence[str] | None = None) -> int:
paths = [Path(argument) for argument in (argv if argv is not None else sys.argv[1:])]
if not paths:
paths = list(DEFAULT_PATHS)
findings = _check_variants_with_paths(paths)
if findings:
for path, finding in findings:
print(f"FAIL {path}: {finding}")
return 1
for path in paths:
print(f"OK {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -20,8 +20,8 @@ from screenshot_catalog import (
def main() -> int: def main() -> int:
errors: list[str] = [] errors: list[str] = []
slugs = canonical_slugs() slugs = canonical_slugs()
if len(slugs) != 38 or len(slugs) != len(set(slugs)): if len(slugs) != 39 or len(slugs) != len(set(slugs)):
errors.append(f"expected 38 unique canonical types; found {len(slugs)}") errors.append(f"expected 39 unique canonical types; found {len(slugs)}")
if not MANIFEST.is_file(): if not MANIFEST.is_file():
errors.append("docs/screenshots/manifest.json is missing") errors.append("docs/screenshots/manifest.json is missing")
@@ -96,7 +96,7 @@ def main() -> int:
for error in errors: for error in errors:
print(f" - {error}") print(f" - {error}")
return 1 return 1
print("OK screenshot freshness: 38 canonical sources and PNG digests match") print("OK screenshot freshness: 39 canonical sources and PNG digests match")
return 0 return 0
+15 -6
View File
@@ -21,6 +21,7 @@ PATTERNS = ROOT / "skills/diagram-design/references/semantic-patterns.md"
ANIMATION = ROOT / "skills/diagram-design/references/animation.md" ANIMATION = ROOT / "skills/diagram-design/references/animation.md"
EXAMPLE = ROOT / "skills/diagram-design/assets/example-policy-trace-animated.html" EXAMPLE = ROOT / "skills/diagram-design/assets/example-policy-trace-animated.html"
MAX_SKILL_BYTES = 40_000 MAX_SKILL_BYTES = 40_000
VISUAL_TYPE_COUNT = 39
PATTERN_NAMES = ( PATTERN_NAMES = (
"Fan-in queue / bottleneck", "Fan-in queue / bottleneck",
@@ -191,18 +192,23 @@ def verify_markdown() -> list[str]:
if "Selection: semantic pattern, then visual type" not in skill: if "Selection: semantic pattern, then visual type" not in skill:
errors.append("SKILL.md must choose semantic pattern before visual type") errors.append("SKILL.md must choose semantic pattern before visual type")
router_position = skill.find("semantic-patterns.md") router_position = skill.find("semantic-patterns.md")
guide_position = skill.find("### Visual-type guide (38)") guide_heading = f"### Visual-type guide ({VISUAL_TYPE_COUNT})"
guide_position = skill.find(guide_heading)
if router_position < 0: if router_position < 0:
errors.append("SKILL.md must link to semantic-patterns.md") errors.append("SKILL.md must link to semantic-patterns.md")
if guide_position < 0: if guide_position < 0:
errors.append("SKILL.md must contain the 38-row visual-type guide") errors.append(
f"SKILL.md must contain the {VISUAL_TYPE_COUNT}-row visual-type guide"
)
if router_position >= 0 and guide_position >= 0 and router_position > guide_position: if router_position >= 0 and guide_position >= 0 and router_position > guide_position:
errors.append("semantic-pattern router must precede the visual-type guide") errors.append("semantic-pattern router must precede the visual-type guide")
visual_guide = section(skill, "### Visual-type guide (38)", "Rules of thumb:") visual_guide = section(skill, guide_heading, "Rules of thumb:")
visual_rows = re.findall(r"^\|.*\[type-[^)]+\.md\]", visual_guide, re.MULTILINE) visual_rows = re.findall(r"^\|.*\[type-[^)]+\.md\]", visual_guide, re.MULTILINE)
if len(visual_rows) != 38: if len(visual_rows) != VISUAL_TYPE_COUNT:
errors.append(f"visual-type guide must preserve 38 rows; found {len(visual_rows)}") errors.append(
f"visual-type guide must preserve {VISUAL_TYPE_COUNT} rows; found {len(visual_rows)}"
)
for index, name in enumerate(PATTERN_NAMES, 1): for index, name in enumerate(PATTERN_NAMES, 1):
if name not in skill: if name not in skill:
@@ -419,7 +425,10 @@ def main() -> int:
print(f"- {error}") print(f"- {error}")
return 1 return 1
if not args.example_only: if not args.example_only:
print("OK: 7 semantic patterns route independently to the preserved 38 visual types") print(
f"OK: 7 semantic patterns route independently to the preserved "
f"{VISUAL_TYPE_COUNT} visual types"
)
print("OK: animation modes, primitives, static fallback, and accessibility contract") print("OK: animation modes, primitives, static fallback, and accessibility contract")
if not args.markdown_only: if not args.markdown_only:
print("OK: policy-trace example controls, structure, reduced motion, and unique IDs") print("OK: policy-trace example controls, structure, reduced motion, and unique IDs")
+9 -5
View File
@@ -1,16 +1,16 @@
--- ---
name: diagram-design name: diagram-design
description: Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, Sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling. description: Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, Sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.
license: MIT license: MIT
metadata: metadata:
version: "2.5" version: "2.6"
--- ---
# 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.
Thirty-eight visual types. Semantic patterns describe behavior independently; type references describe layout. Details load from `references/` only when selected. Thirty-nine visual types. Semantic patterns describe behavior independently; type references describe layout. Details load from `references/` only when selected.
--- ---
@@ -49,7 +49,7 @@ Applied to schematics:
## 2. When to Use ## 2. When to Use
Use for any of the 38 visual types (§3) when a reader will learn more from a visual than from prose, a table, or a bulleted list. Use for any of the 39 visual types (§3) when a reader will learn more from a visual than from prose, a table, or a bulleted list.
**Don't use for:** **Don't use for:**
@@ -78,7 +78,7 @@ When behavior, state, enforcement, or risk carries the meaning, first load [`ref
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. 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 (38) ### Visual-type guide (39)
| If you're showing… | Use | Reference | | If you're showing… | Use | Reference |
|---|---|---| |---|---|---|
@@ -92,6 +92,7 @@ The pattern owns semantic primitives and its tighter budget; the type owns layou
| Cross-functional process with handoffs | **Swimlane** | [type-swimlane.md](references/type-swimlane.md) | | Cross-functional process with handoffs | **Swimlane** | [type-swimlane.md](references/type-swimlane.md) |
| Two-axis positioning / prioritization | **Quadrant** | [type-quadrant.md](references/type-quadrant.md) | | Two-axis positioning / prioritization | **Quadrant** | [type-quadrant.md](references/type-quadrant.md) |
| Multiple entities scored across 3–5 quantitative criteria | **Radar / Spider** | [type-radar.md](references/type-radar.md) | | Multiple entities scored across 3–5 quantitative criteria | **Radar / Spider** | [type-radar.md](references/type-radar.md) |
| One quantitative series across cyclic categories; angle=category, radius=magnitude | **Polar chart** | [type-polar.md](references/type-polar.md) |
| Reinforcing cycle / flywheel where the last step feeds the first and a shared hub accumulates state | **Loop** | [type-loop.md](references/type-loop.md) | | Reinforcing cycle / flywheel where the last step feeds the first and a shared hub accumulates state | **Loop** | [type-loop.md](references/type-loop.md) |
| Hierarchy through containment / scope | **Nested** | [type-nested.md](references/type-nested.md) | | Hierarchy through containment / scope | **Nested** | [type-nested.md](references/type-nested.md) |
| Parent → children relationships | **Tree** | [type-tree.md](references/type-tree.md) | | Parent → children relationships | **Tree** | [type-tree.md](references/type-tree.md) |
@@ -383,6 +384,9 @@ Quick check: if a coordinate ends in 1, 2, 3, 5, 6, 7, 9 — fix it.
| Max radar axes | 5 | | Max radar axes | 5 |
| Max radar series | 5 | | Max radar series | 5 |
| Max focal radar series | 1 | | Max focal radar series | 1 |
| Max polar categories | 8 |
| Max polar series | 1 |
| Max focal polar categories | 1 |
| Max bars (bar chart) | 8 | | Max bars (bar chart) | 8 |
| Max cells (treemap) | 8 | | Max cells (treemap) | 8 |
| Max series (line chart) | 5 | | Max series (line chart) | 5 |
@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request demand by UTC window · Polar chart</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>
html, body, div, main, header, footer, section, article, p, h1, h2, h3, h4, h5, h6 { box-sizing:border-box; margin:0; padding:0; }
:root { --color-paper:#2d3142; --color-ink:#f5f5f5; --color-muted:#bfc0c0; --color-accent:#f08a59; --font-sans:'Geist',system-ui,sans-serif; --font-serif:'Instrument Serif',serif; --font-mono:'Geist Mono',ui-monospace,monospace; }
body { font-family:var(--font-sans); background:var(--color-paper); color:var(--color-ink); min-height:100vh; display:flex; align-items:center; justify-content:center; padding:3rem 2rem; }
.frame { max-width:1200px; width:100%; }
.eyebrow { font-family:var(--font-mono); font-size:0.66rem; font-weight:500; letter-spacing:0.18em; text-transform:uppercase; color:var(--color-muted); margin-bottom:0.5rem; }
h1 { font-family:var(--font-serif); font-size:clamp(1.5rem,2.4vw + 0.75rem,2rem); font-weight:400; letter-spacing:-0.02em; line-height:1.15; color:var(--color-ink); margin-bottom:1.5rem; }
svg { width:100%; min-width:900px; display:block; }
@media (max-height:700px) {
body { padding-top:0.75rem; padding-bottom:0.75rem; }
.frame { max-width:960px; }
.eyebrow { margin-bottom:0.25rem; }
h1 { margin-bottom:0.5rem; }
}
</style>
</head>
<body>
<div class="frame">
<p class="eyebrow">Polar · Diagram Design</p>
<h1>Request demand by UTC window</h1>
<svg viewBox="0 0 1000 520" xmlns="http://www.w3.org/2000/svg"
role="img" aria-labelledby="polar-dark-title polar-dark-desc"
data-polar-chart data-polar-cx="500" data-polar-cy="230"
data-polar-radius="160" data-polar-min="0" data-polar-max="100"
data-polar-start-angle="-90" data-polar-clockwise="true"
data-polar-radius-encoding="linear" data-polar-inner-radius="0">
<title id="polar-dark-title">Request demand by UTC window</title>
<desc id="polar-dark-desc">Polar chart on a 0–100% scale identifying 12–15 UTC as the 100% peak, with the other seven UTC windows proceeding clockwise.</desc>
<rect data-polar-background width="100%" height="100%" fill="#2d3142"/>
<text x="40" y="28" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.14em">% OF DAILY PEAK · 0–100</text>
<circle data-polar-ring cx="500" cy="230" r="32" fill="none" stroke="rgba(245,245,245,0.10)" stroke-width="0.8"/>
<circle data-polar-ring cx="500" cy="230" r="64" fill="none" stroke="rgba(245,245,245,0.10)" stroke-width="0.8"/>
<circle data-polar-ring cx="500" cy="230" r="96" fill="none" stroke="rgba(245,245,245,0.10)" stroke-width="0.8"/>
<circle data-polar-ring cx="500" cy="230" r="128" fill="none" stroke="rgba(245,245,245,0.10)" stroke-width="0.8"/>
<circle data-polar-ring cx="500" cy="230" r="160" fill="none" stroke="rgba(245,245,245,0.20)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="0" x1="500" y1="230" x2="500" y2="70" stroke="rgba(245,245,245,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="1" x1="500" y1="230" x2="613.137" y2="116.863" stroke="rgba(245,245,245,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="2" x1="500" y1="230" x2="660" y2="230" stroke="rgba(245,245,245,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="3" x1="500" y1="230" x2="613.137" y2="343.137" stroke="rgba(245,245,245,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="4" x1="500" y1="230" x2="500" y2="390" stroke="rgba(245,245,245,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="5" x1="500" y1="230" x2="386.863" y2="343.137" stroke="rgba(245,245,245,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="6" x1="500" y1="230" x2="340" y2="230" stroke="rgba(245,245,245,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="7" x1="500" y1="230" x2="386.863" y2="116.863" stroke="rgba(245,245,245,0.16)" stroke-width="0.8"/>
<text x="492" y="201" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">20</text>
<text x="492" y="169" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">40</text>
<text x="492" y="137" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">60</text>
<text x="492" y="105" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">80</text>
<text x="492" y="73" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">100</text>
<text x="508" y="233" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace">0</text>
<g data-polar-category="00–03" data-polar-index="0" data-polar-value="32">
<line data-polar-ray x1="500" y1="230" x2="500" y2="178.8" stroke="#bfc0c0" stroke-width="2"/>
<circle data-polar-marker cx="500" cy="178.8" r="4" fill="#2d3142" stroke="#bfc0c0" stroke-width="1.2"/>
<text x="500" y="42" fill="#f5f5f5" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="middle">00–03</text>
<text data-polar-value-label x="500" y="26" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">32</text>
</g>
<g data-polar-category="03–06" data-polar-index="1" data-polar-value="18">
<line data-polar-ray x1="500" y1="230" x2="520.365" y2="209.635" stroke="#bfc0c0" stroke-width="2"/>
<circle data-polar-marker cx="520.365" cy="209.635" r="4" fill="#2d3142" stroke="#bfc0c0" stroke-width="1.2"/>
<text x="632.936" y="97.064" fill="#f5f5f5" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="start">03–06</text>
<text data-polar-value-label x="644.25" y="85.75" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="start">18</text>
</g>
<g data-polar-category="06–09" data-polar-index="2" data-polar-value="24">
<line data-polar-ray x1="500" y1="230" x2="538.4" y2="230" stroke="#bfc0c0" stroke-width="2"/>
<circle data-polar-marker cx="538.4" cy="230" r="4" fill="#2d3142" stroke="#bfc0c0" stroke-width="1.2"/>
<text x="688" y="230" fill="#f5f5f5" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="start">06–09</text>
<text data-polar-value-label x="704" y="230" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="start" dy="16">24</text>
</g>
<g data-polar-category="09–12" data-polar-index="3" data-polar-value="58">
<line data-polar-ray x1="500" y1="230" x2="565.62" y2="295.62" stroke="#bfc0c0" stroke-width="2"/>
<circle data-polar-marker cx="565.62" cy="295.62" r="4" fill="#2d3142" stroke="#bfc0c0" stroke-width="1.2"/>
<text x="632.936" y="362.936" fill="#f5f5f5" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="start">09–12</text>
<text data-polar-value-label x="644.25" y="374.25" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="start" dy="12">58</text>
</g>
<g data-polar-category="12–15" data-polar-index="4" data-polar-value="100" data-polar-focal="true">
<line data-polar-ray x1="500" y1="230" x2="500" y2="390" stroke="#f08a59" stroke-width="2.4"/>
<circle data-polar-marker cx="500" cy="390" r="5" fill="#2d3142" stroke="#f08a59" stroke-width="1.2"/>
<text x="500" y="418" fill="#f5f5f5" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="middle">12–15</text>
<text data-polar-value-label x="500" y="434" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">100</text>
</g>
<g data-polar-category="15–18" data-polar-index="5" data-polar-value="82">
<line data-polar-ray x1="500" y1="230" x2="407.227" y2="322.773" stroke="#bfc0c0" stroke-width="2"/>
<circle data-polar-marker cx="407.227" cy="322.773" r="4" fill="#2d3142" stroke="#bfc0c0" stroke-width="1.2"/>
<text x="367.064" y="362.936" fill="#f5f5f5" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">15–18</text>
<text data-polar-value-label x="355.75" y="374.25" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end" dy="12">82</text>
</g>
<g data-polar-category="18–21" data-polar-index="6" data-polar-value="76">
<line data-polar-ray x1="500" y1="230" x2="378.4" y2="230" stroke="#bfc0c0" stroke-width="2"/>
<circle data-polar-marker cx="378.4" cy="230" r="4" fill="#2d3142" stroke="#bfc0c0" stroke-width="1.2"/>
<text x="312" y="230" fill="#f5f5f5" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">18–21</text>
<text data-polar-value-label x="296" y="230" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end" dy="16">76</text>
</g>
<g data-polar-category="21–24" data-polar-index="7" data-polar-value="45">
<line data-polar-ray x1="500" y1="230" x2="449.088" y2="179.088" stroke="#bfc0c0" stroke-width="2"/>
<circle data-polar-marker cx="449.088" cy="179.088" r="4" fill="#2d3142" stroke="#bfc0c0" stroke-width="1.2"/>
<text x="367.064" y="97.064" fill="#f5f5f5" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">21–24</text>
<text data-polar-value-label x="355.75" y="85.75" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">45</text>
</g>
<line data-polar-rule x1="40" y1="468" x2="960" y2="468" stroke="rgba(245,245,245,0.10)" stroke-width="0.8"/>
<text x="40" y="488" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.08em">Illustrative normalized workload profile</text>
</svg>
</div>
</body>
</html>
@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request demand by UTC window · Polar chart</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>
html, body, div, main, header, footer, section, article, p, h1, h2, h3, h4, h5, h6 { box-sizing:border-box; margin:0; padding:0; }
:root { --color-paper:#f5f5f5; --color-paper-2:#ececec; --color-ink:#2d3142; --color-muted:#4f5d75; --color-soft:#7a8399; --color-rule:rgba(45,49,66,0.12); --color-accent:#eb6c36; --font-sans:'Geist',system-ui,sans-serif; --font-serif:'Instrument Serif',serif; --font-mono:'Geist Mono',ui-monospace,monospace; }
body { font-family:var(--font-sans); background:var(--color-paper); color:var(--color-ink); min-height:100vh; padding:3rem 2rem; }
.container { max-width:1200px; margin:0 auto; }
.header { margin-bottom:2.5rem; }
.header-eyebrow { font-family:var(--font-mono); font-size:0.66rem; font-weight:500; letter-spacing:0.18em; text-transform:uppercase; color:var(--color-muted); margin-bottom:0.75rem; }
h1 { font-family:var(--font-serif); font-size:clamp(1.75rem,3vw + 1rem,2.5rem); font-weight:400; letter-spacing:-0.02em; line-height:1.1; color:var(--color-ink); margin-bottom:0.5rem; }
.subtitle { font-size:1rem; line-height:1.55; color:var(--color-muted); max-width:58ch; }
.diagram-container { background:var(--color-paper-2); border:1px solid var(--color-rule); border-radius:8px; padding:1.5rem; overflow-x:auto; }
svg { width:100%; min-width:900px; display:block; }
.cards { display:grid; grid-template-columns:1.1fr 1fr 0.9fr; gap:1rem; margin-top:1.5rem; }
@media (max-width:800px) { .cards { grid-template-columns:1fr; } }
.card { background:#ffffff; border:1px solid var(--color-rule); border-radius:6px; padding:1.25rem; }
.card-header { display:flex; align-items:center; gap:0.6rem; margin-bottom:0.75rem; padding-bottom:0.75rem; border-bottom:1px solid rgba(45,49,66,0.08); }
.card-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; background:var(--color-muted); }
.card-dot.coral { background:var(--color-accent); }
.card h3 { font-size:0.875rem; font-weight:600; color:var(--color-ink); }
.card p { color:var(--color-muted); font-size:0.8125rem; line-height:1.55; }
.footer { margin-top:2rem; padding-top:1.5rem; border-top:1px solid rgba(45,49,66,0.10); font-family:var(--font-mono); font-size:0.72rem; letter-spacing:0.06em; color:var(--color-soft); display:flex; justify-content:space-between; flex-wrap:wrap; gap:0.5rem; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<p class="header-eyebrow">Polar · Diagram Design</p>
<h1>Request demand by UTC window</h1>
<p class="subtitle">One normalized workload series across eight UTC windows, with radius showing percent of the daily peak.</p>
</div>
<div class="diagram-container">
<svg viewBox="0 0 1000 520" xmlns="http://www.w3.org/2000/svg"
role="img" aria-labelledby="polar-full-title polar-full-desc"
data-polar-chart data-polar-cx="500" data-polar-cy="230"
data-polar-radius="160" data-polar-min="0" data-polar-max="100"
data-polar-start-angle="-90" data-polar-clockwise="true"
data-polar-radius-encoding="linear" data-polar-inner-radius="0">
<title id="polar-full-title">Request demand by UTC window</title>
<desc id="polar-full-desc">Polar chart on a 0–100% scale identifying 12–15 UTC as the 100% peak, with the other seven UTC windows proceeding clockwise.</desc>
<rect data-polar-background width="100%" height="100%" fill="#f5f5f5"/>
<text x="40" y="28" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.14em">% OF DAILY PEAK · 0–100</text>
<circle data-polar-ring cx="500" cy="230" r="32" fill="none" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<circle data-polar-ring cx="500" cy="230" r="64" fill="none" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<circle data-polar-ring cx="500" cy="230" r="96" fill="none" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<circle data-polar-ring cx="500" cy="230" r="128" fill="none" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<circle data-polar-ring cx="500" cy="230" r="160" fill="none" stroke="rgba(45,49,66,0.20)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="0" x1="500" y1="230" x2="500" y2="70" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="1" x1="500" y1="230" x2="613.137" y2="116.863" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="2" x1="500" y1="230" x2="660" y2="230" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="3" x1="500" y1="230" x2="613.137" y2="343.137" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="4" x1="500" y1="230" x2="500" y2="390" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="5" x1="500" y1="230" x2="386.863" y2="343.137" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="6" x1="500" y1="230" x2="340" y2="230" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="7" x1="500" y1="230" x2="386.863" y2="116.863" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<text x="492" y="201" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">20</text>
<text x="492" y="169" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">40</text>
<text x="492" y="137" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">60</text>
<text x="492" y="105" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">80</text>
<text x="492" y="73" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">100</text>
<text x="508" y="233" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace">0</text>
<g data-polar-category="00–03" data-polar-index="0" data-polar-value="32">
<line data-polar-ray x1="500" y1="230" x2="500" y2="178.8" stroke="#4f5d75" stroke-width="2"/>
<circle data-polar-marker cx="500" cy="178.8" r="4" fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.2"/>
<text x="500" y="42" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="middle">00–03</text>
<text data-polar-value-label x="500" y="26" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">32</text>
</g>
<g data-polar-category="03–06" data-polar-index="1" data-polar-value="18">
<line data-polar-ray x1="500" y1="230" x2="520.365" y2="209.635" stroke="#4f5d75" stroke-width="2"/>
<circle data-polar-marker cx="520.365" cy="209.635" r="4" fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.2"/>
<text x="632.936" y="97.064" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="start">03–06</text>
<text data-polar-value-label x="644.25" y="85.75" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="start">18</text>
</g>
<g data-polar-category="06–09" data-polar-index="2" data-polar-value="24">
<line data-polar-ray x1="500" y1="230" x2="538.4" y2="230" stroke="#4f5d75" stroke-width="2"/>
<circle data-polar-marker cx="538.4" cy="230" r="4" fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.2"/>
<text x="688" y="230" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="start">06–09</text>
<text data-polar-value-label x="704" y="230" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="start" dy="16">24</text>
</g>
<g data-polar-category="09–12" data-polar-index="3" data-polar-value="58">
<line data-polar-ray x1="500" y1="230" x2="565.62" y2="295.62" stroke="#4f5d75" stroke-width="2"/>
<circle data-polar-marker cx="565.62" cy="295.62" r="4" fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.2"/>
<text x="632.936" y="362.936" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="start">09–12</text>
<text data-polar-value-label x="644.25" y="374.25" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="start" dy="12">58</text>
</g>
<g data-polar-category="12–15" data-polar-index="4" data-polar-value="100" data-polar-focal="true">
<line data-polar-ray x1="500" y1="230" x2="500" y2="390" stroke="#eb6c36" stroke-width="2.4"/>
<circle data-polar-marker cx="500" cy="390" r="5" fill="#f5f5f5" stroke="#eb6c36" stroke-width="1.2"/>
<text x="500" y="418" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="middle">12–15</text>
<text data-polar-value-label x="500" y="434" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">100</text>
</g>
<g data-polar-category="15–18" data-polar-index="5" data-polar-value="82">
<line data-polar-ray x1="500" y1="230" x2="407.227" y2="322.773" stroke="#4f5d75" stroke-width="2"/>
<circle data-polar-marker cx="407.227" cy="322.773" r="4" fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.2"/>
<text x="367.064" y="362.936" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">15–18</text>
<text data-polar-value-label x="355.75" y="374.25" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end" dy="12">82</text>
</g>
<g data-polar-category="18–21" data-polar-index="6" data-polar-value="76">
<line data-polar-ray x1="500" y1="230" x2="378.4" y2="230" stroke="#4f5d75" stroke-width="2"/>
<circle data-polar-marker cx="378.4" cy="230" r="4" fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.2"/>
<text x="312" y="230" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">18–21</text>
<text data-polar-value-label x="296" y="230" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end" dy="16">76</text>
</g>
<g data-polar-category="21–24" data-polar-index="7" data-polar-value="45">
<line data-polar-ray x1="500" y1="230" x2="449.088" y2="179.088" stroke="#4f5d75" stroke-width="2"/>
<circle data-polar-marker cx="449.088" cy="179.088" r="4" fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.2"/>
<text x="367.064" y="97.064" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">21–24</text>
<text data-polar-value-label x="355.75" y="85.75" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">45</text>
</g>
<line data-polar-rule x1="40" y1="468" x2="960" y2="468" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<text x="40" y="488" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.08em">Illustrative normalized workload profile</text>
</svg>
</div>
<div class="cards">
<article class="card">
<div class="card-header"><span class="card-dot coral"></span><h3>Peak · 12–15 UTC · 100%</h3></div>
<p>The focal ray reaches the shared 100% outer ring.</p>
</article>
<article class="card">
<div class="card-header"><span class="card-dot"></span><h3>Quietest · 03–06 UTC · 18%</h3></div>
<p>The shortest ray remains visibly proportional to the zero baseline.</p>
</article>
<article class="card">
<div class="card-header"><span class="card-dot"></span><h3>Evening tail · 18–21 UTC · 76%</h3></div>
<p>Demand stays elevated into the final quarter of the clockwise cycle.</p>
</article>
</div>
<div class="footer">
<span>Illustrative data · normalized to daily peak</span>
<span>Polar chart · static SVG</span>
</div>
</div>
</body>
</html>
@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request demand by UTC window · Polar chart</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>
html, body, div, main, header, footer, section, article, p, h1, h2, h3, h4, h5, h6 { box-sizing:border-box; margin:0; padding:0; }
:root { --color-paper:#f5f5f5; --color-ink:#2d3142; --color-muted:#4f5d75; --color-accent:#eb6c36; --font-sans:'Geist',system-ui,sans-serif; --font-serif:'Instrument Serif',serif; --font-mono:'Geist Mono',ui-monospace,monospace; }
body { font-family:var(--font-sans); background:var(--color-paper); color:var(--color-ink); min-height:100vh; display:flex; align-items:center; justify-content:center; padding:3rem 2rem; }
.frame { max-width:1200px; width:100%; }
.eyebrow { font-family:var(--font-mono); font-size:0.66rem; font-weight:500; letter-spacing:0.18em; text-transform:uppercase; color:var(--color-muted); margin-bottom:0.5rem; }
h1 { font-family:var(--font-serif); font-size:clamp(1.5rem,2.4vw + 0.75rem,2rem); font-weight:400; letter-spacing:-0.02em; line-height:1.15; color:var(--color-ink); margin-bottom:1.5rem; }
svg { width:100%; min-width:900px; display:block; }
@media (max-height:700px) {
body { padding-top:0.75rem; padding-bottom:0.75rem; }
.frame { max-width:960px; }
.eyebrow { margin-bottom:0.25rem; }
h1 { margin-bottom:0.5rem; }
}
</style>
</head>
<body>
<div class="frame">
<p class="eyebrow">Polar · Diagram Design</p>
<h1>Request demand by UTC window</h1>
<svg viewBox="0 0 1000 520" xmlns="http://www.w3.org/2000/svg"
role="img" aria-labelledby="polar-title polar-desc"
data-polar-chart data-polar-cx="500" data-polar-cy="230"
data-polar-radius="160" data-polar-min="0" data-polar-max="100"
data-polar-start-angle="-90" data-polar-clockwise="true"
data-polar-radius-encoding="linear" data-polar-inner-radius="0">
<title id="polar-title">Request demand by UTC window</title>
<desc id="polar-desc">Polar chart on a 0–100% scale identifying 12–15 UTC as the 100% peak, with the other seven UTC windows proceeding clockwise.</desc>
<rect data-polar-background width="100%" height="100%" fill="#f5f5f5"/>
<text x="40" y="28" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.14em">% OF DAILY PEAK · 0–100</text>
<circle data-polar-ring cx="500" cy="230" r="32" fill="none" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<circle data-polar-ring cx="500" cy="230" r="64" fill="none" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<circle data-polar-ring cx="500" cy="230" r="96" fill="none" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<circle data-polar-ring cx="500" cy="230" r="128" fill="none" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<circle data-polar-ring cx="500" cy="230" r="160" fill="none" stroke="rgba(45,49,66,0.20)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="0" x1="500" y1="230" x2="500" y2="70" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="1" x1="500" y1="230" x2="613.137" y2="116.863" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="2" x1="500" y1="230" x2="660" y2="230" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="3" x1="500" y1="230" x2="613.137" y2="343.137" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="4" x1="500" y1="230" x2="500" y2="390" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="5" x1="500" y1="230" x2="386.863" y2="343.137" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="6" x1="500" y1="230" x2="340" y2="230" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<line data-polar-spoke data-polar-index="7" x1="500" y1="230" x2="386.863" y2="116.863" stroke="rgba(45,49,66,0.16)" stroke-width="0.8"/>
<text x="492" y="201" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">20</text>
<text x="492" y="169" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">40</text>
<text x="492" y="137" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">60</text>
<text x="492" y="105" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">80</text>
<text x="492" y="73" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">100</text>
<text x="508" y="233" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace">0</text>
<g data-polar-category="00–03" data-polar-index="0" data-polar-value="32">
<line data-polar-ray x1="500" y1="230" x2="500" y2="178.8" stroke="#4f5d75" stroke-width="2"/>
<circle data-polar-marker cx="500" cy="178.8" r="4" fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.2"/>
<text x="500" y="42" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="middle">00–03</text>
<text data-polar-value-label x="500" y="26" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">32</text>
</g>
<g data-polar-category="03–06" data-polar-index="1" data-polar-value="18">
<line data-polar-ray x1="500" y1="230" x2="520.365" y2="209.635" stroke="#4f5d75" stroke-width="2"/>
<circle data-polar-marker cx="520.365" cy="209.635" r="4" fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.2"/>
<text x="632.936" y="97.064" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="start">03–06</text>
<text data-polar-value-label x="644.25" y="85.75" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="start">18</text>
</g>
<g data-polar-category="06–09" data-polar-index="2" data-polar-value="24">
<line data-polar-ray x1="500" y1="230" x2="538.4" y2="230" stroke="#4f5d75" stroke-width="2"/>
<circle data-polar-marker cx="538.4" cy="230" r="4" fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.2"/>
<text x="688" y="230" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="start">06–09</text>
<text data-polar-value-label x="704" y="230" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="start" dy="16">24</text>
</g>
<g data-polar-category="09–12" data-polar-index="3" data-polar-value="58">
<line data-polar-ray x1="500" y1="230" x2="565.62" y2="295.62" stroke="#4f5d75" stroke-width="2"/>
<circle data-polar-marker cx="565.62" cy="295.62" r="4" fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.2"/>
<text x="632.936" y="362.936" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="start">09–12</text>
<text data-polar-value-label x="644.25" y="374.25" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="start" dy="12">58</text>
</g>
<g data-polar-category="12–15" data-polar-index="4" data-polar-value="100" data-polar-focal="true">
<line data-polar-ray x1="500" y1="230" x2="500" y2="390" stroke="#eb6c36" stroke-width="2.4"/>
<circle data-polar-marker cx="500" cy="390" r="5" fill="#f5f5f5" stroke="#eb6c36" stroke-width="1.2"/>
<text x="500" y="418" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="middle">12–15</text>
<text data-polar-value-label x="500" y="434" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">100</text>
</g>
<g data-polar-category="15–18" data-polar-index="5" data-polar-value="82">
<line data-polar-ray x1="500" y1="230" x2="407.227" y2="322.773" stroke="#4f5d75" stroke-width="2"/>
<circle data-polar-marker cx="407.227" cy="322.773" r="4" fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.2"/>
<text x="367.064" y="362.936" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">15–18</text>
<text data-polar-value-label x="355.75" y="374.25" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end" dy="12">82</text>
</g>
<g data-polar-category="18–21" data-polar-index="6" data-polar-value="76">
<line data-polar-ray x1="500" y1="230" x2="378.4" y2="230" stroke="#4f5d75" stroke-width="2"/>
<circle data-polar-marker cx="378.4" cy="230" r="4" fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.2"/>
<text x="312" y="230" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">18–21</text>
<text data-polar-value-label x="296" y="230" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end" dy="16">76</text>
</g>
<g data-polar-category="21–24" data-polar-index="7" data-polar-value="45">
<line data-polar-ray x1="500" y1="230" x2="449.088" y2="179.088" stroke="#4f5d75" stroke-width="2"/>
<circle data-polar-marker cx="449.088" cy="179.088" r="4" fill="#f5f5f5" stroke="#4f5d75" stroke-width="1.2"/>
<text x="367.064" y="97.064" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">21–24</text>
<text data-polar-value-label x="355.75" y="85.75" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">45</text>
</g>
<line data-polar-rule x1="40" y1="468" x2="960" y2="468" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<text x="40" y="488" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.08em">Illustrative normalized workload profile</text>
</svg>
</div>
</body>
</html>
+36 -33
View File
@@ -240,107 +240,110 @@
<button class="tab new" data-type="radar"> <button class="tab new" data-type="radar">
<span class="eyebrow">15</span>Radar <span class="eyebrow">15</span>Radar
</button> </button>
<button class="tab new" data-type="polar">
<span class="eyebrow">16</span>Polar
</button>
<button class="tab new" data-type="loop"> <button class="tab new" data-type="loop">
<span class="eyebrow">16</span>Loop <span class="eyebrow">17</span>Loop
</button> </button>
<button class="tab new" data-type="datalake"> <button class="tab new" data-type="datalake">
<span class="eyebrow">17</span>Data lake <span class="eyebrow">18</span>Data lake
</button> </button>
<button class="tab new" data-type="bar"> <button class="tab new" data-type="bar">
<span class="eyebrow">18</span>Bar <span class="eyebrow">19</span>Bar
</button> </button>
<button class="tab new" data-type="line"> <button class="tab new" data-type="line">
<span class="eyebrow">19</span>Line <span class="eyebrow">20</span>Line
</button> </button>
<button class="tab new" data-type="gantt"> <button class="tab new" data-type="gantt">
<span class="eyebrow">20</span>Gantt <span class="eyebrow">21</span>Gantt
</button> </button>
<button class="tab new" data-type="scatter"> <button class="tab new" data-type="scatter">
<span class="eyebrow">21</span>Scatter <span class="eyebrow">22</span>Scatter
</button> </button>
<button class="tab new" data-type="medallion"> <button class="tab new" data-type="medallion">
<span class="eyebrow">22</span>Medallion <span class="eyebrow">23</span>Medallion
</button> </button>
<button class="tab new" data-type="high-level"> <button class="tab new" data-type="high-level">
<span class="eyebrow">23</span>High-Level <span class="eyebrow">24</span>High-Level
</button> </button>
<button class="tab new" data-type="process"> <button class="tab new" data-type="process">
<span class="eyebrow">24</span>Process <span class="eyebrow">25</span>Process
</button> </button>
<button class="tab new" data-type="data-flow"> <button class="tab new" data-type="data-flow">
<span class="eyebrow">26</span>Data flow <span class="eyebrow">27</span>Data flow
</button> </button>
<button class="tab new" data-type="dp-integration"> <button class="tab new" data-type="dp-integration">
<span class="eyebrow">27</span>DP integration <span class="eyebrow">28</span>DP integration
</button> </button>
<button class="tab new" data-type="high-level-vertical"> <button class="tab new" data-type="high-level-vertical">
<span class="eyebrow">28</span>High-Level / Parametric <span class="eyebrow">29</span>High-Level / Parametric
</button> </button>
<button class="tab new" data-type="treemap"> <button class="tab new" data-type="treemap">
<span class="eyebrow">29</span>Treemap <span class="eyebrow">30</span>Treemap
</button> </button>
<button class="tab new" data-type="slopegraph"> <button class="tab new" data-type="slopegraph">
<span class="eyebrow">30</span>Line · slopegraph <span class="eyebrow">30</span>Line · slopegraph
</button> </button>
<button class="tab new" data-type="dp-security-matrix"> <button class="tab new" data-type="dp-security-matrix">
<span class="eyebrow">32</span>DP security matrix <span class="eyebrow">33</span>DP security matrix
</button> </button>
<button class="tab new" data-type="it-state"> <button class="tab new" data-type="it-state">
<span class="eyebrow">33</span>IT current-state <span class="eyebrow">34</span>IT current-state
</button> </button>
<button class="tab new" data-type="sankey"> <button class="tab new" data-type="sankey">
<span class="eyebrow">34</span>Sankey <span class="eyebrow">35</span>Sankey
</button> </button>
<button class="tab new" data-type="fishbone"> <button class="tab new" data-type="fishbone">
<span class="eyebrow">35</span>Fishbone <span class="eyebrow">36</span>Fishbone
</button> </button>
<button class="tab new" data-type="wardley"> <button class="tab new" data-type="wardley">
<span class="eyebrow">36</span>Wardley map <span class="eyebrow">37</span>Wardley map
</button> </button>
<button class="tab new" data-type="kanban"> <button class="tab new" data-type="kanban">
<span class="eyebrow">37</span>Kanban <span class="eyebrow">38</span>Kanban
</button> </button>
<button class="tab new" data-type="journey"> <button class="tab new" data-type="journey">
<span class="eyebrow">38</span>User journey <span class="eyebrow">39</span>User journey
</button> </button>
<button class="tab new" data-type="deployment"> <button class="tab new" data-type="deployment">
<span class="eyebrow">39</span>Deployment <span class="eyebrow">40</span>Deployment
</button> </button>
<button class="tab new" data-type="dependency"> <button class="tab new" data-type="dependency">
<span class="eyebrow">40</span>Dependency graph <span class="eyebrow">41</span>Dependency graph
</button> </button>
<button class="tab new" data-type="uml-class"> <button class="tab new" data-type="uml-class">
<span class="eyebrow">41</span>UML class <span class="eyebrow">42</span>UML class
</button> </button>
<button class="tab new" data-type="story-map"> <button class="tab new" data-type="story-map">
<span class="eyebrow">42</span>Story map <span class="eyebrow">43</span>Story map
</button> </button>
<button class="tab new" data-type="db-schema"> <button class="tab new" data-type="db-schema">
<span class="eyebrow">43</span>Database schema <span class="eyebrow">44</span>Database schema
</button> </button>
<button class="tab new" data-type="sequence-oauth"> <button class="tab new" data-type="sequence-oauth">
<span class="eyebrow">44</span>Sequence · OAuth <span class="eyebrow">45</span>Sequence · OAuth
</button> </button>
<button class="tab new" data-type="import-drawio" data-single> <button class="tab new" data-type="import-drawio" data-single>
<span class="eyebrow">45</span>Import · draw.io <span class="eyebrow">46</span>Import · draw.io
</button> </button>
<button class="tab new" data-type="import-mermaid" data-single> <button class="tab new" data-type="import-mermaid" data-single>
<span class="eyebrow">46</span>Import · Mermaid <span class="eyebrow">47</span>Import · Mermaid
</button> </button>
<button class="tab new" data-type="quadrant-consultant" data-single> <button class="tab new" data-type="quadrant-consultant" data-single>
<span class="eyebrow">47</span>Quadrant · consultant <span class="eyebrow">48</span>Quadrant · consultant
</button> </button>
<button class="tab new" data-type="loop-terminal" data-single> <button class="tab new" data-type="loop-terminal" data-single>
<span class="eyebrow">48</span>Loop · terminal <span class="eyebrow">49</span>Loop · terminal
</button> </button>
<button class="tab new" data-type="policy-trace-animated" data-single> <button class="tab new" data-type="policy-trace-animated" data-single>
<span class="eyebrow">49</span>Policy trace · animated <span class="eyebrow">50</span>Policy trace · animated
</button> </button>
<button class="tab new" data-type="queue-animated" data-single> <button class="tab new" data-type="queue-animated" data-single>
<span class="eyebrow">50</span>Fan-in queue · animated <span class="eyebrow">51</span>Fan-in queue · animated
</button> </button>
<button class="tab new" data-type="paved-road-animated" data-single> <button class="tab new" data-type="paved-road-animated" data-single>
<span class="eyebrow">51</span>Secure paved road · animated <span class="eyebrow">52</span>Secure paved road · animated
</button> </button>
</div> </div>
@@ -176,7 +176,7 @@
<!-- Main Diagram --> <!-- Main Diagram -->
<div class="diagram-container"> <div class="diagram-container">
<!-- Replace [diagram-slug] with a unique slug for this diagram before export. --> <!-- Replace [diagram-slug] with a unique slug for this diagram before export. -->
<svg viewBox="0 0 1000 700" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="[diagram-slug]-title [diagram-slug]-desc"> <svg viewBox="0 0 1000 720" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="[diagram-slug]-title [diagram-slug]-desc">
<title id="[diagram-slug]-title">[Diagram title]</title> <title id="[diagram-slug]-title">[Diagram title]</title>
<desc id="[diagram-slug]-desc">[One sentence describing what the diagram shows]</desc> <desc id="[diagram-slug]-desc">[One sentence describing what the diagram shows]</desc>
<defs> <defs>
+119
View File
@@ -0,0 +1,119 @@
# Environment doctor
Load this file when the user asks to run diagnostics, health checks, or first-run troubleshooting, or when they invoke `/diagram-design:doctor` or `/doctor`.
The goal is a one-shot report that checks local readiness for Diagram Design import/export and command routing, without mutating user files or installing dependencies.
Resolve the Diagram Design installation from this loaded reference, not from the
user's current working directory. A normal project directory is the expected
place to invoke the doctor and must not be treated as a repository-path error.
Use two diagnostic modes:
- **Installed-skill mode** (default): check the runtime and the resolved skill
installation. Do not require maintainer-only repository files.
- **Maintainer-checkout mode**: use this only when the resolved installation
root contains `CONTRIBUTING.md`, `.github/workflows/ci.yml`, and
`scripts/verify-plugin-package.py`. Add the repository integrity checks below.
## Inputs
Optional flags:
- `--strict` — treat warnings as failures in the final summary.
- `--json` — print a machine-readable JSON report in addition to human summary.
If no flags are provided, run in standard mode.
## Required checks
Run all checks in this order and report each as `pass`, `warn`, or `fail`.
1. Python runtime
- Resolve `python3` first, then `python`.
- Require version >= 3.10.
- `fail` if no Python interpreter is found.
- `fail` if version is below 3.10.
2. Playwright availability for PNG export
- Check whether Playwright import works in the active Python interpreter (`import playwright`).
- Check whether Chromium is installed for Playwright (`playwright install --help` availability is sufficient for command presence; prefer also checking browser cache when practical).
- If missing, mark `warn` and print exact setup hint:
- `pip install playwright && playwright install chromium`
- Never auto-install dependencies.
3. Expected script presence (maintainer-checkout mode only)
- Verify these repository scripts exist:
- `scripts/verify-drawio-import.py`
- `scripts/verify-mermaid-import.py`
- `scripts/verify-motion.py`
- `scripts/lint-skin.py`
- `scripts/verify-docs-sync.py`
- Missing scripts are `fail` in maintainer-checkout mode.
- In installed-skill mode, report that maintainer scripts are not applicable;
their absence is not a warning or failure.
4. Plugin wiring surfaces (maintainer-checkout mode only)
- Verify Claude command files exist and point to their references:
- `commands/export-diagram.md` -> `references/export.md`
- `commands/import-drawio.md` -> `references/import-drawio.md`
- `commands/import-mermaid.md` -> `references/import-mermaid.md`
- `commands/profile.md` -> `references/profiles.md`
- `commands/doctor.md` -> `references/doctor.md`
- Verify Pi prompt files exist and point to their references:
- `prompts/export-diagram.md` -> `references/export.md`
- `prompts/import-mermaid.md` -> `references/import-mermaid.md`
- `prompts/profile.md` -> `references/profiles.md`
- `prompts/doctor.md` -> `references/doctor.md`
- Missing files are `fail`.
- Mismatched reference routing is `fail`.
- In installed-skill mode, report that maintainer command/prompt wiring is not
applicable; partial or absent repository routing trees are not failures.
5. Common path mistakes
- Verify `SKILL.md` beneath the resolved installation root. Do not search for it
relative to the user's current project and do not instruct users to enter the
maintainer repository.
- Detect Windows path quoting risk when paths contain spaces and the provided command examples omit quotes.
- Detect references to local installed skill paths that do not exist (if command output includes one).
- Mark these as `warn` with a precise fix suggestion.
- A missing resolved `SKILL.md` should suggest reinstalling or updating Diagram
Design, not changing into a repository checkout.
## Output contract
Always print:
1. A compact summary line:
- `Doctor summary: <PASS|WARN|FAIL> (<pass_count> pass, <warn_count> warn, <fail_count> fail)`
2. A checklist with one line per check:
- `[PASS] Python 3.11.9 found at ...`
- `[WARN] Playwright not installed ...`
- `[FAIL] Missing scripts/verify-docs-sync.py`
3. A `Next actions` section only when warn/fail exists.
4. If `--json` is present, append JSON object with:
- `status`, `counts`, `checks[]` (`name`, `status`, `message`, `fix` optional), `timestamp`.
## Safety and behavior rules
- Read-only diagnostics only: do not modify files, do not install packages, do not run destructive git commands.
- If any command fails unexpectedly, capture stderr and continue remaining checks.
- Never claim a check passed unless verified directly in this run.
- Prefer explicit, copy-pastable remediation commands.
## Example result
```text
Doctor summary: WARN (6 pass, 2 warn, 0 fail)
[PASS] Python 3.11.9 found at /usr/bin/python3
[WARN] Playwright package not found in active interpreter
[PASS] scripts/verify-drawio-import.py present
...
Next actions
- Install PNG export dependencies: pip install playwright && playwright install chromium
- Re-run: /diagram-design:doctor --strict
```
@@ -159,7 +159,7 @@ Write the new tokens to `style-guide.md`. Suggest running the `/regenerate-examp
After onboarding, the user should: After onboarding, the user should:
1. Open `assets/index.html` (gallery) and confirm the new palette feels coherent across all 38 types. 1. Open `assets/index.html` (gallery) and confirm the new palette feels coherent across all 39 types.
2. If any type looks off, they usually need to tune `muted` (often too dark or too light against the new `paper`). 2. If any type looks off, they usually need to tune `muted` (often too dark or too light against the new `paper`).
--- ---
@@ -1,6 +1,6 @@
# Semantic patterns # Semantic patterns
Semantic patterns describe **what a system does**; the 38 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. Semantic patterns describe **what a system does**; the 39 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. 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.
@@ -420,7 +420,7 @@ Before emitting SVG, verify **every** item. If any fails, fix it — don't ship.
1. Every cluster `node.cx` equals its chevron's `cx` (§2.2 + §2.7). This is what makes the chevron banner a real legend. 1. Every cluster `node.cx` equals its chevron's `cx` (§2.2 + §2.7). This is what makes the chevron banner a real legend.
2. Every chevron `width` is a multiple of 4 and ≥ 120. 2. Every chevron `width` is a multiple of 4 and ≥ 120.
3. The reserved right strip (28 px) exists **iff** any vertical chevron is declared. If yes, `effective_w = 972`; if no, `effective_w = 1000`. 3. The reserved right strip (28 px) exists **iff** any vertical chevron is declared. If yes, `effective_w = 964`; if no, `effective_w = 1000`.
4. Exactly **one** `focal` node. If `focal` is unset in inputs, default to the first `kind: node` under chevron "Storage". 4. Exactly **one** `focal` node. If `focal` is unset in inputs, default to the first `kind: node` under chevron "Storage".
5. Every edge whose endpoint is the focal node uses `style: primary` (accent stroke + `arrow-accent` marker). 5. Every edge whose endpoint is the focal node uses `style: primary` (accent stroke + `arrow-accent` marker).
6. Every edge originating from a `kind: bar` component uses `style: trigger` (dashed + `arrow-sm`). 6. Every edge originating from a `kind: bar` component uses `style: trigger` (dashed + `arrow-sm`).
@@ -430,7 +430,7 @@ Before emitting SVG, verify **every** item. If any fails, fix it — don't ship.
10. Each vertical chevron pairs **1:1** with exactly one `bar` or `cross-cutting` component (§5 pairing rule). `len(verticals) == len(bars) + len(crosscuts)`. 10. Each vertical chevron pairs **1:1** with exactly one `bar` or `cross-cutting` component (§5 pairing rule). `len(verticals) == len(bars) + len(crosscuts)`.
11. `viewBox_h = max(540, strip_y_bot + 112)` — grow the canvas when multiple crosscuts are declared so the legend still fits. 11. `viewBox_h = max(540, strip_y_bot + 112)` — grow the canvas when multiple crosscuts are declared so the legend still fits.
12. Custom component colors (§3.4) apply only to container + icon + name; connectors stay topology-driven. Cap at 2 custom-colored components in addition to the focal. 12. Custom component colors (§3.4) apply only to container + icon + name; connectors stay topology-driven. Cap at 2 custom-colored components in addition to the focal.
11. The diagram passes SKILL.md §9 (4-px grid; ≤ 2 accent elements; mono only for technical content; hairlines; no shadows; no `rounded-2xl`). 13. The diagram passes SKILL.md §9 (4-px grid; ≤ 2 accent elements; mono only for technical content; hairlines; no shadows; no `rounded-2xl`).
--- ---
@@ -0,0 +1,136 @@
# Polar Chart
**Best for:** one quantitative series across 4–8 categories whose clockwise order is meaningful.
## Input contract
```yaml
title: "Request demand by UTC window"
unit: "% of daily peak"
scale:
min: 0
max: 100
categories:
- { label: "00–03", value: 32 }
- { label: "03–06", value: 18 }
- { label: "06–09", value: 24 }
- { label: "09–12", value: 58 }
- { label: "12–15", value: 100, focal: true }
- { label: "15–18", value: 82 }
- { label: "18–21", value: 76 }
- { label: "21–24", value: 45 }
start_angle: -90
clockwise: true
source_note: "Illustrative normalized workload profile"
```
Validation rules:
1. `scale.min` is exactly `0`; truncated radial scales are invalid.
2. `scale.max` is finite and greater than `0`.
3. Every value is finite and satisfies `0 <= value <= scale.max`.
4. Category labels are unique after trimming and category count is 4–8.
5. At most one category is focal.
6. Categories retain input order; sorting by value would destroy circular meaning.
Missing values are out of scope for the first version. The agent must stop and ask whether to omit the category or supply a value; it must not coerce missing data to zero.
## Quantitative encoding
For category `i` of `N`, value `v`, center `(cx, cy)`, and outer radius `R`:
```text
theta_i = start_angle + clockwise_sign * 2π * i / N
radius_i = R * v / scale.max
x_i = cx + radius_i * cos(theta_i)
y_i = cy + radius_i * sin(theta_i)
```
Angles are expressed in radians for calculation; `start_angle` is supplied in degrees and defaults to `-90`, placing the first category at twelve o'clock. `clockwise_sign` is `+1` for the default clockwise order and `-1` otherwise.
The value ray is the line segment from `(cx, cy)` to `(x_i, y_i)`. Its visible length is exactly proportional to `v`. Endpoint circles have constant radius and encode no additional quantity. No filled sector may sit behind or beneath a value ray, because its area would compete with the declared radius encoding.
The element carrying `data-polar-chart` must be the chart's root `<svg>`. Its descendants are limited to `<g>`, `<line>`, `<circle>`, `<rect>`, `<text>`, `<title>`, and `<desc>`; nested SVGs, paths, polygons, external references, images, and every other element are rejected. Duplicate attributes, mismatched markup, URL-valued SVG references, transforms, CSS loading through `@import`/`url()`, and geometry-affecting CSS are forbidden. Chart elements carry neither `class` nor inline `style`; document styles may not use selectors capable of matching the chart, except the canonical root `svg` layout rule. The only external stylesheet allowed is Google Fonts. Every line carries exactly one of `data-polar-spoke`, `data-polar-ray`, or `data-polar-rule` and uses the role's declared stroke width; spoke and ray geometry is verified within `min(0.75 px, 5% of R)`. The five grid circles carry `data-polar-ring`, use `fill="none"`, a 0.8 px stroke, the chart center, and the 20% through 100% radius intervals exactly once. Every endpoint circle carries `data-polar-marker` and uses the declared 4 px radius, or 5 px for the focal category, with a 1.2 px stroke. The optional full-canvas background is the only rectangle and carries `data-polar-background`. This fail-closed allowlist prevents contributors from bypassing the radius-only encoding with an unannotated or falsely annotated filled shape.
### Zero
For `v = 0`, `radius_i = 0`. Render no value ray and no endpoint marker. Keep the faint full-radius category spoke, and render the category's numeric label as `0`. A single scale label at the center also marks the shared zero baseline. Do not add a minimum hub, minimum ray length, displaced marker, or other visible magnitude.
## Layout conventions
Version 1 supports:
- one quantitative series;
- 4–8 equally spaced categories in a meaningful clockwise order;
- a shared linear scale with `min = 0` and `max > 0`;
- five circular grid rings at 20%, 40%, 60%, 80%, and 100%;
- one optional focal category rendered with the accent token;
- minimal light, minimal dark, and full-editorial examples;
- horizontal, upright category labels outside the outer ring;
- one numeric value label per category.
Version 1 excludes:
- DISC, personality, competency, and role-profile wheels whose sectors are qualitative;
- arbitrary point placement within sectors;
- filled quantitative wedges, gradient bands, or donut hubs;
- multiple series, negative values, logarithmic scales, unequal sector angles, and more than eight categories;
- animation or interaction.
Use Radar for multiple entities scored across common criteria, Line for more than eight ordered time buckets, and Bar when circular order adds no meaning.
## Geometry
All three examples use `viewBox="0 0 1000 520"`, center `(500, 230)`, and `R = 160`.
- Grid rings: `r = 32, 64, 96, 128, 160`.
- Category spokes: center to `R`, 0.8 px rule stroke, no arrowhead.
- Non-focal value rays: 2 px muted stroke; endpoint marker radius 4.
- Focal value ray: 2.4 px accent stroke; endpoint marker radius 5.
- Ring labels: `0.2 × max` through `1.0 × max` on the first axis only, Geist Mono 8 px; the example therefore shows `20, 40, 60, 80, 100`.
- Category labels: at `R + 28`, Geist Sans 11 px semibold, horizontal and upright.
- Numeric labels: at `R + 44`, Geist Mono 8 px, including the unit in the chart subtitle rather than repeating it eight times.
- Label anchor: `middle` within 15 degrees of vertical, `start` on the right half, and `end` on the left half.
- Drawing order: background, rings, spokes, scale labels, non-focal rays, focal ray, endpoint markers, category labels, numeric labels, legend/source note.
The minimal examples use the full 1000×520 canvas. The full-editorial example places the same chart in the existing editorial frame and adds summary cards without changing chart geometry.
## Visual treatment
- One accent category maximum. All other value rays use the muted token.
- Circular rings and spokes are structural grid marks, never category colors.
- Endpoint markers are constant-size lollipop heads, not bubbles.
- Category labels remain horizontal; tangent rotation is intentionally excluded because it lowers scanability and belongs to the rejected qualitative wheel direction.
- The chart must remain understandable in grayscale: position and numeric labels carry the data; accent only directs attention.
- The SVG accessible description names the peak category, the scale, and the clockwise category order without narrating geometry.
## Complexity budget
- 4–8 categories.
- Exactly one quantitative series.
- At most one focal category.
- Five grid rings and one numeric value label per category.
- Static output only; split or change type rather than adding interaction or another encoding.
## When not to use
- Multiple series → use Radar.
- Non-cyclic categories → use Bar.
- More than eight ordered time buckets → use Line.
- Qualitative profiles, arbitrary sector placement, negative values, or logarithmic scales → choose a truthful non-polar representation.
## Anti-patterns
- Donut hubs or any non-zero inner baseline.
- Filled wedges, sectors, or gradient bands that imply area encodes magnitude.
- Tangent or rotated category labels.
- Multiple series on the same polar chart.
- Truncated scales whose minimum is not zero.
- Categories sorted by value instead of retaining their meaningful circular order.
- Treating a missing value as zero.
## Examples
- `assets/example-polar.html` — minimal light.
- `assets/example-polar-dark.html` — minimal dark with the same dataset and geometry.
- `assets/example-polar-full.html` — full editorial with three unequal summary cards and unchanged chart geometry.