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:
co-authored by
mysoniw
thang
YinShaokun
0xDarkMatter
Rupam Pal
Yuvraj3905
parent
4b573616cb
commit
b827385a17
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "diagram-design",
|
||||
"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": {
|
||||
"name": "Cathryn Lavery"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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.",
|
||||
"version": "2.5.20",
|
||||
"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.6.0",
|
||||
"author": {
|
||||
"name": "Cathryn Lavery",
|
||||
"url": "https://github.com/cathrynlavery"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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.",
|
||||
"version": "2.5.20",
|
||||
"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.6.0",
|
||||
"author": {
|
||||
"name": "Cathryn Lavery",
|
||||
"url": "https://github.com/cathrynlavery"
|
||||
@@ -26,7 +26,7 @@
|
||||
"interface": {
|
||||
"displayName": "Diagram Design",
|
||||
"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",
|
||||
"category": "Productivity",
|
||||
"capabilities": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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.",
|
||||
"version": "2.5.20",
|
||||
"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.6.0",
|
||||
"author": {
|
||||
"name": "Cathryn Lavery",
|
||||
"url": "https://github.com/cathrynlavery"
|
||||
|
||||
@@ -142,6 +142,14 @@ jobs:
|
||||
id: skin
|
||||
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
|
||||
if: always()
|
||||
id: oauth
|
||||
@@ -164,6 +172,16 @@ jobs:
|
||||
id: motion
|
||||
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
|
||||
if: always()
|
||||
id: docs_sync
|
||||
@@ -221,6 +239,46 @@ jobs:
|
||||
id: slopegraph_tests
|
||||
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
|
||||
if: always()
|
||||
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 "| 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 "| 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 "| Draw.io Import Extractor | ${{ steps.drawio.outcome == 'success' && '✅ Passed' || (steps.drawio.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Mermaid Import Extractor | ${{ steps.mermaid.outcome == 'success' && '✅ Passed' || (steps.mermaid.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Optional Motion Contract | ${{ steps.motion.outcome == 'success' && '✅ Passed' || (steps.motion.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| 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 "| 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
|
||||
@@ -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 "| 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 "| 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 "| 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
@@ -8,7 +8,7 @@ Please read [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) first. All contributions ar
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
@@ -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` |
|
||||
| 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` |
|
||||
| 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 |
|
||||
|
||||
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-motion.py --shipped \
|
||||
&& 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-drawio-import.py \
|
||||
&& python3 scripts/test-verify-drawio-import.py \
|
||||
&& python3 scripts/verify-mermaid-import.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/test-verify-docs-sync.py \
|
||||
&& python3 scripts/verify-screenshot-freshness.py \
|
||||
@@ -94,7 +106,9 @@ python3 scripts/test-plugin-package.py \
|
||||
&& python3 scripts/verify-dumbbell.py \
|
||||
&& python3 scripts/test-verify-dumbbell.py \
|
||||
&& 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
|
||||
@@ -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`.
|
||||
- **`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-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-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.
|
||||
|
||||
@@ -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.*
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -32,7 +32,7 @@ So I built a Claude Code skill for it. Thirty-eight visual types, editorial qual
|
||||
|
||||
## 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>
|
||||
<tr>
|
||||
@@ -98,13 +98,13 @@ All 38 visual types ship in three static variants: minimal light, minimal dark,
|
||||
<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/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>
|
||||
</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).
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -213,7 +213,7 @@ Every diagram template gives the inline SVG an accessible name and description:
|
||||
|
||||
### 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
|
||||
|
||||
@@ -234,7 +234,7 @@ The profile library is shared across Claude Code, Codex, Factory Droid, and Pi.
|
||||
## Quickstart
|
||||
|
||||
```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
|
||||
xdg-open skills/diagram-design/assets/index.html # Linux
|
||||
|
||||
@@ -357,11 +357,13 @@ diagram-design/
|
||||
│ ├── export-diagram.md — plugin export command
|
||||
│ ├── import-drawio.md — plugin draw.io 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/
|
||||
│ ├── export-diagram.md — Pi `/export-diagram` 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/
|
||||
│ └── diagram-design/
|
||||
│ ├── SKILL.md — philosophy, selection guide, checklist
|
||||
@@ -409,7 +411,7 @@ diagram-design/
|
||||
│ └── assets/
|
||||
│ ├── index.html — live gallery, tabbed
|
||||
│ ├── 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-quadrant-consultant.html
|
||||
│ ├── example-import-drawio.html
|
||||
@@ -418,11 +420,18 @@ diagram-design/
|
||||
│ └── example-sequence-oauth*.html
|
||||
├── scripts/
|
||||
│ ├── 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-plugin-package.py — version + marketplace package gate
|
||||
│ ├── 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/
|
||||
│ ├── sample-flowchart.mmd
|
||||
│ ├── 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.
|
||||
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`).
|
||||
|
||||
`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
|
||||
|
||||
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` |
|
||||
| "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 |
|
||||
| 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -22,7 +22,8 @@ Accepts `.drawio`, `.drawio.xml`, `.xml`, `.drawio.png`, and `.drawio.svg`.
|
||||
- `--detail=balanced` · `--audience=mixed`.
|
||||
- `--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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ Accepts `.mmd`, `.mermaid`, and Markdown files containing fenced `mermaid` block
|
||||
- `--detail=balanced` · `--audience=mixed`.
|
||||
- `--variant=light` — the minimal light template.
|
||||
- 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
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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:
|
||||
- Read
|
||||
- Write
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR 0002 — Semantic patterns never expand the visual-type taxonomy
|
||||
|
||||
**Status:** accepted (v2.3)
|
||||
**Status:** accepted (v2.3; amended v2.6)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -8,7 +8,7 @@ Auditing behavior-rich figures (queues, policy traces, trust boundaries) showed
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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-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.
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- `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.
|
||||
|
||||
@@ -101,6 +101,15 @@
|
||||
"width": 2400,
|
||||
"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",
|
||||
"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.
|
||||
@@ -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.
|
||||
Executable
+813
@@ -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())
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/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
|
||||
|
||||
@@ -21,8 +21,8 @@ from screenshot_catalog import (
|
||||
|
||||
def main() -> int:
|
||||
slugs = canonical_slugs()
|
||||
if len(slugs) != 38 or len(slugs) != len(set(slugs)):
|
||||
raise SystemExit(f"expected 38 unique canonical types; found {len(slugs)}")
|
||||
if len(slugs) != 39 or len(slugs) != len(set(slugs)):
|
||||
raise SystemExit(f"expected 39 unique canonical types; found {len(slugs)}")
|
||||
|
||||
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
entries: list[dict[str, object]] = []
|
||||
@@ -56,7 +56,7 @@ def main() -> int:
|
||||
"height": height,
|
||||
}
|
||||
)
|
||||
print(f"[{index:02d}/38] {slug}: {width}x{height}")
|
||||
print(f"[{index:02d}/39] {slug}: {width}x{height}")
|
||||
browser.close()
|
||||
|
||||
payload = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/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
|
||||
|
||||
@@ -13,6 +13,37 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
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():
|
||||
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"
|
||||
profile_reference = root / "skills/diagram-design/references/profiles.md"
|
||||
command = root / "commands/profile.md"
|
||||
prompt = root / "prompts/profile.md"
|
||||
for path in (profile_reference, command, prompt):
|
||||
doctor_reference = root / "skills/diagram-design/references/doctor.md"
|
||||
export_reference = root / "skills/diagram-design/references/export.md"
|
||||
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)
|
||||
profile_reference.write_text("# Profiles\n", encoding="utf-8")
|
||||
command.write_text("Follow references/profiles.md.\n", encoding="utf-8")
|
||||
prompt.write_text("Follow references/profiles.md.\n", encoding="utf-8")
|
||||
doctor_reference.write_text("# Doctor\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 = []
|
||||
verify.check_profile_surfaces(errors, root)
|
||||
verify.check_routing_surfaces(errors, root)
|
||||
if errors:
|
||||
raise AssertionError(f"valid profile surfaces failed: {errors}")
|
||||
raise AssertionError(f"valid routing surfaces failed: {errors}")
|
||||
|
||||
prompt.unlink()
|
||||
doctor_prompt.unlink()
|
||||
errors = []
|
||||
verify.check_profile_surfaces(errors, root)
|
||||
expected = "profile surface is missing: prompts/profile.md"
|
||||
verify.check_routing_surfaces(errors, root)
|
||||
expected = "routing surface is missing: prompts/doctor.md"
|
||||
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 = []
|
||||
verify.check_profile_surfaces(errors, root)
|
||||
verify.check_routing_surfaces(errors, root)
|
||||
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]:
|
||||
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_marketplace = root / ".factory-plugin/marketplace.json"
|
||||
@@ -262,9 +330,98 @@ diagram-design/
|
||||
if errors != [expected]:
|
||||
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(
|
||||
"PASS: docs sync checks references, strict-bundler packaging, profile surfaces, "
|
||||
"and Factory install contract"
|
||||
"PASS: docs sync checks references, strict-bundler packaging, routing surfaces, "
|
||||
"Factory install contract, type-count routing, and High-Level invariants"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -402,7 +402,7 @@ def main() -> int:
|
||||
missing_guide = directory / "missing-guide.md"
|
||||
missing_guide.write_text(
|
||||
original_skill.read_text(encoding="utf-8").replace(
|
||||
"### Visual-type guide (38)", "### Visual guide"
|
||||
"### Visual-type guide (39)", "### Visual guide"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -410,7 +410,7 @@ def main() -> int:
|
||||
markdown_errors = semantic_module.verify_markdown()
|
||||
finally:
|
||||
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}")
|
||||
print("OK: missing visual-type guide anchor is rejected")
|
||||
|
||||
|
||||
@@ -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("M0 0L100 0");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
+131
-18
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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
|
||||
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.
|
||||
3. Every concrete file named in README.md's architecture tree must exist.
|
||||
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
|
||||
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
|
||||
@@ -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.
|
||||
8. Every support path a strict skill bundler can extract from SKILL.md must be
|
||||
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
|
||||
@@ -34,17 +38,26 @@ SKILL = ROOT / "skills/diagram-design/SKILL.md"
|
||||
GALLERY = ROOT / "skills/diagram-design/assets/index.html"
|
||||
ASSET_DIR = ROOT / "skills/diagram-design/assets"
|
||||
README = ROOT / "README.md"
|
||||
HIGH_LEVEL_REFERENCE = ROOT / "skills/diagram-design/references/type-high-level.md"
|
||||
VARIANTS = ("", "-dark", "-full")
|
||||
VISUAL_TYPE_COUNT = 39
|
||||
# Types whose selection-table name differs from its description vocabulary.
|
||||
DESCRIPTION_ALIASES = {
|
||||
"bar chart": "bar",
|
||||
"line chart": "line",
|
||||
"scatter plot": "scatter",
|
||||
}
|
||||
PROFILE_SURFACES = (
|
||||
Path("commands/profile.md"),
|
||||
Path("prompts/profile.md"),
|
||||
)
|
||||
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",
|
||||
}
|
||||
FACTORY_MANIFEST = Path(".factory-plugin/plugin.json")
|
||||
FACTORY_MARKETPLACE = Path(".factory-plugin/marketplace.json")
|
||||
SUPPORT_DIRECTORIES = frozenset(
|
||||
@@ -102,8 +115,10 @@ def check_description(errors: list[str]) -> None:
|
||||
errors.append("SKILL.md frontmatter description is missing")
|
||||
return
|
||||
types = selection_table_types(markdown)
|
||||
if len(types) != 38:
|
||||
errors.append(f"expected 38 visual types in the selection table; found {len(types)}")
|
||||
if len(types) != VISUAL_TYPE_COUNT:
|
||||
errors.append(
|
||||
f"expected {VISUAL_TYPE_COUNT} visual types in the selection table; found {len(types)}"
|
||||
)
|
||||
for name in types:
|
||||
key = normalized(name)
|
||||
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:
|
||||
reference = root / "skills/diagram-design/references/profiles.md"
|
||||
def check_routing_surfaces(errors: list[str], root: Path) -> None:
|
||||
for relative, reference_link in ROUTING_SURFACES.items():
|
||||
reference = root / "skills/diagram-design" / reference_link
|
||||
if not reference.is_file():
|
||||
errors.append("profile source of truth is missing: skills/diagram-design/references/profiles.md")
|
||||
for relative in PROFILE_SURFACES:
|
||||
errors.append(f"routing source of truth is missing: skills/diagram-design/{reference_link}")
|
||||
path = root / relative
|
||||
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
|
||||
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(
|
||||
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 = (
|
||||
(Path(".claude-plugin/plugin.json"), ("description",)),
|
||||
(Path(".claude-plugin/marketplace.json"), ("description",)),
|
||||
@@ -340,7 +451,9 @@ def main() -> int:
|
||||
SKILL.read_text(encoding="utf-8"),
|
||||
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:
|
||||
print("FAIL docs sync")
|
||||
for error in errors:
|
||||
@@ -348,8 +461,8 @@ def main() -> int:
|
||||
return 1
|
||||
print(
|
||||
"OK docs sync: description hooks, gallery reachability, README tree, "
|
||||
"reference links, packaged support files, profile surfaces, manifest descriptions, "
|
||||
"Factory install contract"
|
||||
"reference links, packaged support files, routing surfaces, manifest descriptions, "
|
||||
"Factory install contract, type-count routing, High-Level invariants"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -20,8 +20,8 @@ from screenshot_catalog import (
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
slugs = canonical_slugs()
|
||||
if len(slugs) != 38 or len(slugs) != len(set(slugs)):
|
||||
errors.append(f"expected 38 unique canonical types; found {len(slugs)}")
|
||||
if len(slugs) != 39 or len(slugs) != len(set(slugs)):
|
||||
errors.append(f"expected 39 unique canonical types; found {len(slugs)}")
|
||||
|
||||
if not MANIFEST.is_file():
|
||||
errors.append("docs/screenshots/manifest.json is missing")
|
||||
@@ -96,7 +96,7 @@ def main() -> int:
|
||||
for error in errors:
|
||||
print(f" - {error}")
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ PATTERNS = ROOT / "skills/diagram-design/references/semantic-patterns.md"
|
||||
ANIMATION = ROOT / "skills/diagram-design/references/animation.md"
|
||||
EXAMPLE = ROOT / "skills/diagram-design/assets/example-policy-trace-animated.html"
|
||||
MAX_SKILL_BYTES = 40_000
|
||||
VISUAL_TYPE_COUNT = 39
|
||||
|
||||
PATTERN_NAMES = (
|
||||
"Fan-in queue / bottleneck",
|
||||
@@ -191,18 +192,23 @@ def verify_markdown() -> list[str]:
|
||||
if "Selection: semantic pattern, then visual type" not in skill:
|
||||
errors.append("SKILL.md must choose semantic pattern before visual type")
|
||||
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:
|
||||
errors.append("SKILL.md must link to semantic-patterns.md")
|
||||
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:
|
||||
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)
|
||||
if len(visual_rows) != 38:
|
||||
errors.append(f"visual-type guide must preserve 38 rows; found {len(visual_rows)}")
|
||||
if len(visual_rows) != VISUAL_TYPE_COUNT:
|
||||
errors.append(
|
||||
f"visual-type guide must preserve {VISUAL_TYPE_COUNT} rows; found {len(visual_rows)}"
|
||||
)
|
||||
|
||||
for index, name in enumerate(PATTERN_NAMES, 1):
|
||||
if name not in skill:
|
||||
@@ -419,7 +425,10 @@ def main() -> int:
|
||||
print(f"- {error}")
|
||||
return 1
|
||||
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")
|
||||
if not args.markdown_only:
|
||||
print("OK: policy-trace example controls, structure, reduced motion, and unique IDs")
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
---
|
||||
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
|
||||
metadata:
|
||||
version: "2.5"
|
||||
version: "2.6"
|
||||
---
|
||||
|
||||
# Diagram Design
|
||||
|
||||
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
|
||||
|
||||
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:**
|
||||
|
||||
@@ -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.
|
||||
|
||||
### Visual-type guide (38)
|
||||
### Visual-type guide (39)
|
||||
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| Hierarchy through containment / scope | **Nested** | [type-nested.md](references/type-nested.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 series | 5 |
|
||||
| Max focal radar series | 1 |
|
||||
| Max polar categories | 8 |
|
||||
| Max polar series | 1 |
|
||||
| Max focal polar categories | 1 |
|
||||
| Max bars (bar chart) | 8 |
|
||||
| Max cells (treemap) | 8 |
|
||||
| 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>
|
||||
@@ -240,107 +240,110 @@
|
||||
<button class="tab new" data-type="radar">
|
||||
<span class="eyebrow">15</span>Radar
|
||||
</button>
|
||||
<button class="tab new" data-type="polar">
|
||||
<span class="eyebrow">16</span>Polar
|
||||
</button>
|
||||
<button class="tab new" data-type="loop">
|
||||
<span class="eyebrow">16</span>Loop
|
||||
<span class="eyebrow">17</span>Loop
|
||||
</button>
|
||||
<button class="tab new" data-type="datalake">
|
||||
<span class="eyebrow">17</span>Data lake
|
||||
<span class="eyebrow">18</span>Data lake
|
||||
</button>
|
||||
<button class="tab new" data-type="bar">
|
||||
<span class="eyebrow">18</span>Bar
|
||||
<span class="eyebrow">19</span>Bar
|
||||
</button>
|
||||
<button class="tab new" data-type="line">
|
||||
<span class="eyebrow">19</span>Line
|
||||
<span class="eyebrow">20</span>Line
|
||||
</button>
|
||||
<button class="tab new" data-type="gantt">
|
||||
<span class="eyebrow">20</span>Gantt
|
||||
<span class="eyebrow">21</span>Gantt
|
||||
</button>
|
||||
<button class="tab new" data-type="scatter">
|
||||
<span class="eyebrow">21</span>Scatter
|
||||
<span class="eyebrow">22</span>Scatter
|
||||
</button>
|
||||
<button class="tab new" data-type="medallion">
|
||||
<span class="eyebrow">22</span>Medallion
|
||||
<span class="eyebrow">23</span>Medallion
|
||||
</button>
|
||||
<button class="tab new" data-type="high-level">
|
||||
<span class="eyebrow">23</span>High-Level
|
||||
<span class="eyebrow">24</span>High-Level
|
||||
</button>
|
||||
<button class="tab new" data-type="process">
|
||||
<span class="eyebrow">24</span>Process
|
||||
<span class="eyebrow">25</span>Process
|
||||
</button>
|
||||
<button class="tab new" data-type="data-flow">
|
||||
<span class="eyebrow">26</span>Data flow
|
||||
<span class="eyebrow">27</span>Data flow
|
||||
</button>
|
||||
<button class="tab new" data-type="dp-integration">
|
||||
<span class="eyebrow">27</span>DP integration
|
||||
<span class="eyebrow">28</span>DP integration
|
||||
</button>
|
||||
<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 class="tab new" data-type="treemap">
|
||||
<span class="eyebrow">29</span>Treemap
|
||||
<span class="eyebrow">30</span>Treemap
|
||||
</button>
|
||||
<button class="tab new" data-type="slopegraph">
|
||||
<span class="eyebrow">30</span>Line · slopegraph
|
||||
</button>
|
||||
<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 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 class="tab new" data-type="sankey">
|
||||
<span class="eyebrow">34</span>Sankey
|
||||
<span class="eyebrow">35</span>Sankey
|
||||
</button>
|
||||
<button class="tab new" data-type="fishbone">
|
||||
<span class="eyebrow">35</span>Fishbone
|
||||
<span class="eyebrow">36</span>Fishbone
|
||||
</button>
|
||||
<button class="tab new" data-type="wardley">
|
||||
<span class="eyebrow">36</span>Wardley map
|
||||
<span class="eyebrow">37</span>Wardley map
|
||||
</button>
|
||||
<button class="tab new" data-type="kanban">
|
||||
<span class="eyebrow">37</span>Kanban
|
||||
<span class="eyebrow">38</span>Kanban
|
||||
</button>
|
||||
<button class="tab new" data-type="journey">
|
||||
<span class="eyebrow">38</span>User journey
|
||||
<span class="eyebrow">39</span>User journey
|
||||
</button>
|
||||
<button class="tab new" data-type="deployment">
|
||||
<span class="eyebrow">39</span>Deployment
|
||||
<span class="eyebrow">40</span>Deployment
|
||||
</button>
|
||||
<button class="tab new" data-type="dependency">
|
||||
<span class="eyebrow">40</span>Dependency graph
|
||||
<span class="eyebrow">41</span>Dependency graph
|
||||
</button>
|
||||
<button class="tab new" data-type="uml-class">
|
||||
<span class="eyebrow">41</span>UML class
|
||||
<span class="eyebrow">42</span>UML class
|
||||
</button>
|
||||
<button class="tab new" data-type="story-map">
|
||||
<span class="eyebrow">42</span>Story map
|
||||
<span class="eyebrow">43</span>Story map
|
||||
</button>
|
||||
<button class="tab new" data-type="db-schema">
|
||||
<span class="eyebrow">43</span>Database schema
|
||||
<span class="eyebrow">44</span>Database schema
|
||||
</button>
|
||||
<button class="tab new" data-type="sequence-oauth">
|
||||
<span class="eyebrow">44</span>Sequence · OAuth
|
||||
<span class="eyebrow">45</span>Sequence · OAuth
|
||||
</button>
|
||||
<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 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 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 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 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 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 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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
<!-- Main Diagram -->
|
||||
<div class="diagram-container">
|
||||
<!-- 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>
|
||||
<desc id="[diagram-slug]-desc">[One sentence describing what the diagram shows]</desc>
|
||||
<defs>
|
||||
|
||||
@@ -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:
|
||||
|
||||
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`).
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
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".
|
||||
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`).
|
||||
@@ -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)`.
|
||||
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.
|
||||
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.
|
||||
Reference in New Issue
Block a user