diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a87f2bb..26f8c2a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 781becd..3f41ba9 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 715c78a..8ef7cbf 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -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": [ diff --git a/.factory-plugin/plugin.json b/.factory-plugin/plugin.json index 781becd..3f41ba9 100644 --- a/.factory-plugin/plugin.json +++ b/.factory-plugin/plugin.json @@ -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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 329918f..3a411f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0714edb..7d3b0b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index 3e0f5f9..7245293 100644 --- a/README.md +++ b/README.md @@ -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. @@ -98,13 +98,13 @@ All 38 visual types ship in three static variants: minimal light, minimal dark, - +
Story map
Story map
Backbone × release slices
Database schema
Database schema
Physical tables + column FKs
Polar chart
Polar chart
Cyclic magnitude · linear radius
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-.html — 3 variants × 38 types +│ ├── example-.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 +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. diff --git a/commands/doctor.md b/commands/doctor.md new file mode 100644 index 0000000..bfd09a4 --- /dev/null +++ b/commands/doctor.md @@ -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. diff --git a/commands/import-drawio.md b/commands/import-drawio.md index 2416a6b..403727a 100644 --- a/commands/import-drawio.md +++ b/commands/import-drawio.md @@ -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 diff --git a/commands/import-mermaid.md b/commands/import-mermaid.md index 7a7891e..5579da2 100644 --- a/commands/import-mermaid.md +++ b/commands/import-mermaid.md @@ -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 diff --git a/commands/profile.md b/commands/profile.md index 727df6f..57caa8a 100644 --- a/commands/profile.md +++ b/commands/profile.md @@ -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 diff --git a/docs/adr/0002-semantic-patterns-do-not-expand-the-taxonomy.md b/docs/adr/0002-semantic-patterns-do-not-expand-the-taxonomy.md index 79e8a26..9b28327 100644 --- a/docs/adr/0002-semantic-patterns-do-not-expand-the-taxonomy.md +++ b/docs/adr/0002-semantic-patterns-do-not-expand-the-taxonomy.md @@ -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. diff --git a/docs/adr/0007-new-layout-grammars.md b/docs/adr/0007-new-layout-grammars.md index 85766cd..0f3b937 100644 --- a/docs/adr/0007-new-layout-grammars.md +++ b/docs/adr/0007-new-layout-grammars.md @@ -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. diff --git a/docs/screenshots/manifest.json b/docs/screenshots/manifest.json index d98fa57..866796b 100644 --- a/docs/screenshots/manifest.json +++ b/docs/screenshots/manifest.json @@ -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", diff --git a/docs/screenshots/polar.png b/docs/screenshots/polar.png new file mode 100644 index 0000000..22e4d72 Binary files /dev/null and b/docs/screenshots/polar.png differ diff --git a/docs/superpowers/plans/2026-08-18-polar-chart.md b/docs/superpowers/plans/2026-08-18-polar-chart.md new file mode 100644 index 0000000..bfbcdf2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-polar-chart.md @@ -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 = '{}'.format(value) + if endpoint is not None: + x, y = endpoint + body = ( + f'' + f'' + body + ) + return ( + f'{body}' + ) + + +def document(categories: str, extra_svg: str = "") -> str: + return ( + '' + '' + f'{extra_svg}{categories}' + ) + + +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('0', '0'), "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('', ''), "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 : ` and return 1 if any finding exists; otherwise print `OK ` 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 + +``` + +Use `` grid rings at `r=32/64/96/128/160`, eight faint full-radius spokes, then one group per category: + +```html + + + + 32 + +``` + +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 `` 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 + +``` + +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 +Polar chart
Polar chart
Cyclic magnitude · linear radius +``` + +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. diff --git a/docs/superpowers/specs/2026-08-18-polar-chart-design.md b/docs/superpowers/specs/2026-08-18-polar-chart-design.md new file mode 100644 index 0000000..555550c --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-polar-chart-design.md @@ -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 ``. Its descendants are limited to ``, ``, ``, ``, ``, ``, and ``; 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 + +``` + +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. diff --git a/prompts/doctor.md b/prompts/doctor.md new file mode 100644 index 0000000..2f643ed --- /dev/null +++ b/prompts/doctor.md @@ -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. diff --git a/scripts/lint-render.py b/scripts/lint-render.py new file mode 100755 index 0000000..c244c2d --- /dev/null +++ b/scripts/lint-render.py @@ -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", '', "", None, True), + ( + "thick-stroke-spill", + '', + "", + None, + True, + ), + ( + "marker-spill", + '' + '' + '', + "", + None, + True, + ), + ( + "filter-bleed", + '' + '', + "", + None, + True, + ), + ( + "clip-path-keeps-it-safe", + '' + '', + "", + None, + False, + ), + ( + "opacity-0-ancestor", + '', + "", + None, + False, + ), + ( + "display-none-ancestor", + '', + "", + None, + False, + ), + ("all-inside", '', "", None, False), + # The author set overflow visible: the svg itself clips nothing. + ( + "overflow-visible-svg", + '', + "overflow:visible", + None, + False, + ), + # ... but a wrapper still clips it, and staging must catch that. + ( + "overflow-visible-svg-in-clipping-wrapper", + '', + "overflow:visible", + '
', + True, + ), + # Wrapper clipping declared inline, and via a class — both must be released. + ( + "inline-wrapper", + '', + "", + '
', + True, + ), + ( + "class-wrapper", + '', + "", + '
', + True, + ), + # A rounded clipping wrapper repaints its corners when released; not spill. + ( + "rounded-wrapper", + '', + "background:#eee", + '
', + False, + ), + # An authored inline transform must survive the scale pass untouched. + ( + "authored-transform", + '', + "transform:rotate(2deg)", + None, + True, + ), +] + +FIXTURE_PAGE = """ +%(filler)s%(open)s +%(markup)s +%(close)s +""" + +# Enough filler to push the svg below a 1000px-tall viewport, so the paired +# above/below-fold cases exercise scrolled measurement. +BELOW_FOLD_FILLER = '
' + + +def fixture_html(markup, svg_style="", wrapper=None, filler=""): + return FIXTURE_PAGE % { + "filler": filler, + "open": wrapper or "", + "close": "
" 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( + "", + 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='
') + ) + 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( + '
wide
' + '' + ) + 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('', 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()) diff --git a/scripts/render-canonical-screenshots.py b/scripts/render-canonical-screenshots.py index dd717b3..3a9009f 100644 --- a/scripts/render-canonical-screenshots.py +++ b/scripts/render-canonical-screenshots.py @@ -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 = { diff --git a/scripts/test-verify-docs-sync.py b/scripts/test-verify-docs-sync.py index 84b436f..623682f 100644 --- a/scripts/test-verify-docs-sync.py +++ b/scripts/test-verify-docs-sync.py @@ -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 /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 diff --git a/scripts/test-verify-doctor.py b/scripts/test-verify-doctor.py new file mode 100644 index 0000000..4591fe4 --- /dev/null +++ b/scripts/test-verify-doctor.py @@ -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()) diff --git a/scripts/test-verify-motion.py b/scripts/test-verify-motion.py index 35bc02e..5a4c1fb 100644 --- a/scripts/test-verify-motion.py +++ b/scripts/test-verify-motion.py @@ -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") diff --git a/scripts/test-verify-polar.py b/scripts/test-verify-polar.py new file mode 100644 index 0000000..be43509 --- /dev/null +++ b/scripts/test-verify-polar.py @@ -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 = '{}'.format(value) + if endpoint is not None: + x, y = endpoint + body = ( + f'' + f'' + body + ) + return ( + f'{body}' + ) + + +def document(categories: str, extra_svg: str = "") -> str: + return ( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + f'{extra_svg}{categories}' + ) + + +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( + '0', + '' + '0', + ), + "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('', ''), + "wedge", + ), + ( + "unmarked quantitative wedge", + VALID.replace( + '', + '', + ), + "wedge", + ), + ( + "polygon area encoding", + VALID.replace( + '', + '', + ), + "unsupported SVG element ", + ), + ( + "external referenced shape", + VALID.replace('', ''), + "unsupported SVG element ", + ), + ( + "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( + '', + '', + ), + "external stylesheets are forbidden in polar documents", + ), + ( + "CSS geometry override", + VALID.replace( + '', + '', + ), + "CSS geometry properties are forbidden in polar documents", + ), + ( + "CSS imported stylesheet", + VALID.replace( + '', + '', + ), + "CSS stylesheet loading is forbidden in polar documents", + ), + ( + "CSS marker radius override", + VALID.replace( + '', + '', + ), + "CSS geometry properties are forbidden in polar documents", + ), + ( + "CSS marker position override", + VALID.replace( + '', + '', + ), + "CSS geometry properties are forbidden in polar documents", + ), + ( + "CSS all shorthand override", + VALID.replace( + '', + '', + ), + "CSS selectors must not target data-polar-chart", + ), + ( + "CSS individual scale override", + VALID.replace( + '', + '', + ), + "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 ", + ), + ( + "duplicate marker radius", + VALID.replace('r="4" stroke-width="1.2"', + 'r="40" r="4" stroke-width="1.2"', 1), + "duplicate attribute r on ", + ), + ( + "duplicate ring fill", + VALID.replace('r="20" fill="none"', + 'r="20" fill="red" fill="none"', 1), + "duplicate attribute fill on ", + ), + ( + "mismatched chart end tag", + VALID.replace( + '', + '', + ), + "mismatched closing tag ", + ), + ( + "nested SVG", + VALID.replace('', ''), + "unsupported SVG element ", + ), + ( + "unmarked filled circle", + VALID.replace( + '', '' + ), + "circle must be a ring or category marker", + ), + ( + "unmarked rectangle", + VALID.replace( + '', '' + ), + "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( + '', + '', + ), + "ring must use fill none", + ), + ( + "off-grid ring", + VALID.replace( + '', + '', + ), + "ring radius 70 is not a 20% scale interval", + ), + ( + "thick grid ring", + VALID.replace( + '', + '', + ), + "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( + '', + '', + ), + "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( + '', + '', + ), + "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( + '', + '', + ), + "spoke indices must be contiguous 0..3", + ), + ( + "thick chart rule", + VALID.replace( + '', + '', + ), + "rule stroke-width 40 does not match expected 0.8", + ), + ( + "duplicate chart rules", + VALID.replace( + '', + '' + '', + ), + "chart rule count is 2, expected at most one", + ), + ( + "transformed chart geometry", + VALID.replace('', '
'), + "data-polar-chart must be an SVG element", + ), + ( + "blank value label", + VALID.replace( + '25', + ' ', + ), + "finite numeric", + ), + ( + "mismatched value label", + VALID.replace( + '25', + '26', + ), + "does not match", + ), + ( + "hidden value label", + VALID.replace( + '25', + '', + ), + "explicitly hidden", + ), + ( + "aria-hidden value label", + VALID.replace( + '25', + '', + ), + "explicitly hidden", + ), + ( + "display-none value label", + VALID.replace( + '25', + '25', + ), + "explicitly hidden", + ), + ( + "visibility-hidden value label", + VALID.replace( + '25', + '25', + ), + "explicitly hidden", + ), + ( + "opacity-zero value label", + VALID.replace( + '25', + '25', + ), + "explicitly hidden", + ), + ( + "direct display-none value label", + VALID.replace( + '25', + '25', + ), + "explicitly hidden", + ), + ( + "direct visibility-hidden value label", + VALID.replace( + '25', + '25', + ), + "explicitly hidden", + ), + ( + "direct opacity-zero value label", + VALID.replace( + '25', + '25', + ), + "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()) diff --git a/scripts/test-verify-sankey.py b/scripts/test-verify-sankey.py new file mode 100644 index 0000000..aa91d9c --- /dev/null +++ b/scripts/test-verify-sankey.py @@ -0,0 +1,1047 @@ +#!/usr/bin/env python3 +"""Adversarial tests for verify-sankey.py — both polarities. + +Per ADR 0005, a geometric contract in this repo is a checker plus fixtures that +prove it fires when it should and stays quiet when it shouldn't. Each mutation +below is a defect that renders perfectly and that no other gate would catch: +a ribbon that narrows as it travels, a node that loses volume between its two +sides, a stage that quietly carries less than the one before it, a printed +number that no longer matches the bar beside it, and a dark variant edited by +hand until it no longer matches the light one. + +The later cases attack the checker rather than the figure. A checker that cannot +see a shape reports success, and success is indistinguishable from a clean file — +so a path it cannot parse, a ribbon attached to nothing, node bars outside the +size window, a shipped variant that has gone missing, and a reference code sample +that no longer matches the example all have to be findings, and each is tested +here. + +Every anchor below is a literal excerpt of the SHIPPED example-sankey.html — +the CI budget figure — so a fixture that fails to build means the example +moved, which is itself worth knowing. + +Usage: python3 scripts/test-verify-sankey.py +Exit: 0 all pass, 1 a case failed. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CHECKER = ROOT / "scripts/verify-sankey.py" +ASSETS = ROOT / "skills/diagram-design/assets" +GOOD = ASSETS / "example-sankey.html" +REFERENCE = ROOT / "skills/diagram-design/references/type-sankey.md" + +# The first ribbon of the shipped figure (Budget → Unit tests), in full and as +# the opening fragment mutations prepend attributes to. +RIBBON = ('') +RIBBON_ANCHOR = ' tuple[int, str]: + result = subprocess.run( + argv, capture_output=True, text=True, + encoding="utf-8", errors="replace", env=CHILD_ENV, + ) + return result.returncode, (result.stdout or "") + (result.stderr or "") + + +def run(*paths: Path) -> tuple[int, str]: + return invoke([sys.executable, str(CHECKER), *(str(p) for p in paths)]) + + +def write(directory: Path, name: str, source: str) -> Path: + path = directory / name + path.write_text(source, encoding="utf-8") + return path + + +def case( + failures: list[str], + directory: Path, + name: str, + source: str, + original: str, + expect: str, + describe: str, +) -> None: + """Run one mutation: it must be a real edit, and it must be rejected.""" + if source == original: + failures.append(f"could not build the {name} fixture (anchor moved)") + return + code, output = run(write(directory, f"{name}.html", source)) + if code == 0: + failures.append(f"{describe} was accepted") + elif expect not in output: + failures.append(f"{describe} was reported without the expected reason: {output.strip()}") + else: + print(f"OK: {describe} is rejected") + + +def refill(source_text: str, colour: str) -> str: + """Repaint the first ribbon. Its fill is the volume, so this is the value + every conservation sum is measured against.""" + return source_text.replace(RIBBON_FILL, f'fill="{colour}"', 1) + + +def onribbon(source_text: str, extra: str) -> str: + """Add an attribute to the first ribbon, leaving every coordinate alone.""" + return source_text.replace( + RIBBON_ANCHOR, f' int: + source = GOOD.read_text(encoding="utf-8") + failures: list[str] = [] + + with tempfile.TemporaryDirectory() as raw: + directory = Path(raw) + + # 1. The shipped example must pass untouched. + code, output = run(GOOD) + if code != 0: + failures.append(f"clean example was rejected: {output.strip()}") + else: + print("OK: the shipped sankey passes") + + # 2. Narrow one end of a ribbon. The Unit-tests → Passed band arrives + # 10px thinner than it left — invisible by eye across a 368px span, + # because a reader has no reference width to compare it against. The + # return-curve controls move with the edge so only the width lies. + case( + failures, directory, "tapered", + source.replace( + "L880,236 C696,236", + "L880,226 C696,226", + ), + source, "change in width", + "a ribbon that narrows along its run", + ) + + # 3. Break conservation at a node while keeping the ribbon's own width + # constant: the E2E node then takes in 80px and puts out 72px, and + # nothing about the picture looks wrong. + case( + failures, directory, "leaky-node", + source.replace( + "L880,312 C696,312 696,336 512,336 Z", + "L880,304 C696,304 696,328 512,328 Z", + ), + source, "as tall as the flow", + "a node that puts out less than it takes in", + ) + + # 4. Shrink the last node so the final stage carries less than the two + # before it. This is the mutation that justifies the stage-total check + # existing separately from the per-node one. + case( + failures, directory, "stage", + source.replace( + '9,400 min<", ">7,400 min<"), + source, "off the scale", + "a node labelled with a value its height does not draw", + ) + + # 6. Move one Bezier control onto the target's x. Every endpoint stays + # exactly where it was, the width is unchanged, and conservation still + # balances — but the control now coincides with the endpoint, B'(1) + # vanishes, and the ribbon skews into its node bar. Nothing else here + # reads the control points, so nothing else would catch it. + case( + failures, directory, "slanted", + source.replace( + 'd="M132,120 C316,120 316,120 500,120', + 'd="M132,120 C316,120 500,120 500,120', + ), + source, "meets its node bar at a slant", + "a ribbon whose control point collapses onto its endpoint", + ) + + # 7. A path this checker cannot read must be reported, never skipped. + case( + failures, directory, "unreadable", + source.replace( + "M132,120 C316,120 316,120 500,120 L500,224 C316,224 316,224 132,224 Z", + "M132,120 L500,120 L500,224 L132,224 Z", + ), + source, "neither a readable ribbon", + "a path the checker cannot parse as a ribbon", + ) + + # 7. Detach a ribbon from the node it leaves. It still renders, and it is + # counted in no node's balance — so it has to be reported, not skipped. + case( + failures, directory, "unattached", + source.replace('d="M132,224 C316,224', 'd="M172,224 C316,224'), + source, "touches nothing", + "a ribbon that leaves from no node bar", + ) + + # 8. Delete every node bar. The checker then sees ribbons and no nodes, + # and must say it checked nothing rather than exit clean. (It used to + # be enough to WIDEN the bars past the parser window; the rect-shape + # rule now refuses that earlier, so reaching this guard takes an + # actual deletion — and the guard is worth keeping reachable, because + # a vacuous pass is the one failure a green checker cannot show you.) + case( + failures, directory, "no-bars", + re.sub(r'[ ]*\n', '', source), + source, "Nothing here could be checked", + "a file whose node bars the parser cannot see", + ) + + # 8a. A rect that is none of the three shapes a sankey draws — not a node + # bar, not a legend swatch, not the full-bleed paper. Painted last it + # covers whatever was measured, and widening the bars is how the + # parser was blinded before the rule existed. + case( + failures, directory, "fat-bars", + source.replace('width="12" height=', 'width="20" height='), + source, "neither a node bar, a legend swatch", + "a rect that is not any shape this figure draws", + ) + + # 8a-bis. A rect's SIZE is not enough — its position decides what it may + # claim to be. A swatch-sized 16x8 rect is a legend key below the + # band floor and ink over the figure inside it, where it is exactly + # big enough to cover a printed value. And a bar-width rect BELOW + # the band is a shape parse_bars will never read: unrefused, it is + # ink this checker certified without examining. + case( + failures, directory, "swatch-in-plot", + source.replace(ANNOT, + ' \n' + + ANNOT, 1), + source, "neither a node bar", + "a swatch-sized rect parked inside the plot band", + ) + case( + failures, directory, "bar-below-band", + source.replace(ANNOT, + ' \n' + + ANNOT, 1), + source, "neither a node bar", + "a bar-width rect below the plot band", + ) + + # 8b. Strip every ribbon. Eight node bars remain, so the file still looks + # like a sankey — but there is no flow left at all, and every + # conservation check would pass on it vacuously. + case( + failures, directory, "no-ribbons", + "\n".join(l for l in source.splitlines() if '\n' + anchor, 1) + if dashed == source: + failures.append("could not build the dashed-relationship fixture (anchor moved)") + else: + code, output = run(write(directory, "dashed.html", dashed)) + if code != 0: + failures.append(f"the documented dashed non-volume path was rejected: {output.strip()}") + else: + print("OK: a dashed unfilled path is allowed, as the reference promises") + + case( + failures, directory, "filled-garbage", + source.replace(anchor, + ' \n' + anchor, 1), + source, "neither a readable ribbon", + "a filled path that is not a readable ribbon", + ) + + # The exemption is narrow on purpose, and each clause needs its own + # case. "Unfilled" alone is not enough: a fill="none" path with a fat + # stroke renders as a band a reader reads as volume, and would skip + # every check below. Width cap and dash pattern are both load-bearing. + # Two routes reject these, and each case expects the one that fires. The + # width cap catches the fat pair first and diagnoses them more directly; + # the undashed hairline is thin enough to reach the exemption, which + # turns it down for want of a dash pattern. + for name, frag, expect, why in ( + ("fat-stroke", + '', + "paints", "an unfilled path drawn as a 40px band"), + ("fat-dashed", + '', + "paints", "a dashed unfilled path too wide to be a hairline"), + ("undashed", + '', + "neither a readable ribbon", "an unfilled hairline with no dash pattern"), + ): + case( + failures, directory, name, + source.replace(anchor, " " + frag + "\n" + anchor, 1), + source, expect, why, + ) + + # 8e. Blank a node's printed value. Its drawn height is then compared + # against nothing, and the fidelity check quietly shrinks its sample + # rather than failing — which is how a label-format change once + # stopped every value from parsing without a single test noticing. + case( + failures, directory, "novalue", + source.replace(">12,000 min<", ">the whole budget<", 1), + source, "no readable value beside it", + "a node bar whose printed value cannot be read", + ) + + # 8f. Run a ribbon end past the bottom of the bar it attaches to. Its top + # edge is still inside, so a top-edge-only test counts it as attached + # and credits the volume to the wrong node. + case( + failures, directory, "overrun", + source.replace("C696,368 696,408 512,408 Z", "C696,368 696,428 512,428 Z", 1), + source, "touches nothing", + "a ribbon end overrunning the bar it attaches to", + ) + + # 8g. Set stroke-width from CSS instead of an attribute. The attributes + # this checker reads still say "hairline"; what renders is a band. + # A class does it from the page stylesheet, inline style does it on + # the element, and the same trick widens a real ribbon past the fill + # it is measured by — so geometry may not carry style or class at + # all. All three were live holes found in review. + styled = source.replace( + anchor, + ' \n' + anchor, 1) + case( + failures, directory, "css-inline", styled, source, + "apparent volume", "a hairline widened by an inline style", + ) + + classed = source.replace( + anchor, + ' \n' + anchor, 1 + ).replace("", re.IGNORECASE | re.DOTALL) +ANY_ELEMENT_RE = re.compile(r"<(?P[a-z][\w:.-]*)\b(?P[^>]*)>", re.IGNORECASE) +CONTAINER_RE = re.compile(r"<(?Psvg|g)\b(?P[^>]*)>", re.IGNORECASE) +# Properties that decide whether a shape renders, and how wide. Any of them can +# reach a ribbon from a stylesheet or an ancestor without appearing on the ribbon +# itself - stroke-width was closed that way already, and fill arrived by exactly +# the same two routes one review later. So the rule is the property set, not the +# property: none of these may be declared anywhere this checker cannot read them. +INDIRECT_PAINT_PROPS = ("stroke-width", "fill", "fill-opacity", "opacity") +# The SVG number grammar, written once and used everywhere a number is read: +# path coordinates, lengths and alphas. It has been written four times in this +# file - once per reviewer - and each copy learned a different part of it. The +# forms that keep getting missed are all legal and all mean what they look +# like: ".75" is 0.75, "+1" is 1, "1.5e-1" is 0.15. A pattern built around the +# spellings the shipped example happens to use rejects a contributor who writes +# the same figure a different way, which is a gate failing at its actual job - +# and worse, a leading-dot coordinate read by "-?\\d+" silently becomes a +# DIFFERENT number rather than no number at all. +SVG_NUMBER = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?" +NUMBER = SVG_NUMBER + +# Alpha can be written five ways and this file got it wrong three times by +# pattern-matching whichever one was in front of it. So: parse the colour's +# STRUCTURE. Legacy rgba(r, g, b, a); modern rgb(r g b / a) with any function +# name; #rgba and #rrggbbaa; the keyword `transparent`; and everything else, +# which is opaque. Anything shaped like a colour but not readable is reported +# rather than assumed either way. +HEX_COLOR_RE = re.compile(r"^#(?P[0-9a-f]+)$", re.IGNORECASE) +FUNC_COLOR_RE = re.compile(r"^(?P[a-z-]+)\(\s*(?P.*?)\s*\)$", re.IGNORECASE | re.DOTALL) +ALPHA_TOKEN_RE = re.compile(r"^(?P" + SVG_NUMBER + r")(?P%?)$") +# The SVG number grammar, not the subset this file happens to use. ".75", "+1" +# and "1.5e-1" are all legal and all mean 0.75, 1 and 0.15 - writing a regex +# around the forms in the shipped example rejects a contributor who writes the +# same width a different way, which is a gate failing at its actual job. +LENGTH_RE = re.compile( + r"^(?P" + SVG_NUMBER + r")\s*(?P[a-z%]*)$", + re.IGNORECASE, +) +# Absolute units convert to px exactly (CSS Values 3). Relative ones resolve +# against a font or a viewport this checker never sees, so a number carrying one +# cannot be compared to a pixel threshold at all - say so rather than guess. +PX_PER_UNIT = { + "": 1.0, "px": 1.0, "pt": 96 / 72, "pc": 16.0, + "in": 96.0, "cm": 96 / 2.54, "mm": 96 / 25.4, "q": 96 / 101.6, +} +# Whether a dash pattern survives is a different question from what it measures. +# A browser drops `stroke-dasharray` whole if any component is not a valid +# length, and draws a solid line - but where every unit IS valid, the sign of +# the number settles positivity on its own, whatever the unit later resolves +# to. So the dash test needs VALIDITY across the whole CSS length set, not +# resolvability across the absolute subset above: `4em 3em` and `4% 3%` do +# genuinely dash, and `4foo 3foo` renders as the solid line this form is not. +# +# Built from the structure CSS Values 4 gives them rather than hand-listed, +# because hand-listing overshot in both directions inside two review rounds: +# first `4foo` was accepted as a dash, then the correction rejected `4cap`, +# `4lh`, `4dvh`, `4cqw` and eleven other real units. The stems below are the +# spec's own families (§5.1.1 font-relative, §5.1.2 viewport-relative with its +# four viewport-size prefixes, §5.1.3 container-relative, §5.2 absolute). +# +# When CSS adds a unit this list will be stale, and the failure mode of that +# staleness is a REJECTION naming the unit it did not know - loud, and one line +# to fix. That is the right way round for a gate; the other way round is a +# silent pass. +_VIEWPORT_AXES = ("vw", "vh", "vi", "vb", "vmin", "vmax") +CSS_LENGTH_UNITS = frozenset( + ("", "%") + + ("cm", "mm", "q", "in", "pc", "pt", "px") # absolute + + ("em", "rem", "ex", "rex", "cap", "rcap", # font-relative + "ch", "rch", "ic", "ric", "lh", "rlh") + + tuple(prefix + axis # viewport-relative + for prefix in ("", "s", "l", "d") for axis in _VIEWPORT_AXES) + + tuple("cq" + axis[1:] for axis in _VIEWPORT_AXES) # container-relative +) +NUMBER_RE = re.compile(SVG_NUMBER) +# Numbers FIRST, because the alternative is what broke this: scanning for +# [A-Za-z] independently reads the "e" of 5.2e2 as a path command, so a +# conforming ribbon written in exponent form was rejected as unparseable. +# Matching the number before the letter means an exponent is consumed as part +# of the number it belongs to and never offered as a command. +PATH_TOKEN_RE = re.compile(SVG_NUMBER + r"|[A-Za-z]") + + +def path_tokens(d: str) -> tuple[list, list]: + """A path's commands and its coordinates, in one pass over the data.""" + commands, numbers = [], [] + for token in PATH_TOKEN_RE.findall(d): + if token.isalpha(): + commands.append(token.upper()) + else: + numbers.append(float(token)) + return commands, numbers +# The canonical ribbon: a move, a cubic down one edge, a straight end cap, a +# cubic back along the other edge, close. Anything else in the figure is not a +# ribbon - and a path that does not match is REPORTED rather than skipped, +# because a checker that silently parses nothing is worse than no checker. +RIBBON_COMMANDS = ["M", "C", "L", "C", "Z"] +RIBBON_COORDS = 16 +TEXT_RE = re.compile(r"[^>]*)>(?P.*?)", re.IGNORECASE | re.DOTALL) +# Reading attributes with a `name="value"` shape looked fine and was wrong four +# ways at once, each of which turns EVERY guard below into a no-op without +# touching a coordinate: a single-quoted or unquoted value is simply not seen, +# so style='stroke-width:30' sails past the ban on style; a repeated attribute +# resolves the opposite way from a browser, which keeps the FIRST; a character +# reference is never decoded, so fill="none" reads as a colour named +# "none"; and a ">" inside a quoted value ends the element early for any +# pattern built on [^>]*. So this is a small tokeniser rather than a shape +# match, and it is the ONE place markup becomes attributes. +OPEN_TAG_RE = re.compile( + r"""<(?P[a-zA-Z][\w:.-]*)(?P(?:"[^"]*"|'[^']*'|[^>"'])*)/?>""" +) +ATTRIBUTE_RE = re.compile( + r"""(?P[^\s=/>]+)\s*(?:=\s*(?P"[^"]*"|'[^']*'|[^\s>]*))?""" +) +COMMENT_RE = re.compile(r"", re.DOTALL) + + +def attributes(raw: str) -> dict: + """One element's attributes, resolved the way a browser resolves them.""" + found: dict = {} + for m in ATTRIBUTE_RE.finditer(raw): + name = m.group("name").lower() + value = m.group("value") or "" + if len(value) >= 2 and value[0] in ('"', "'") and value[-1] == value[0]: + value = value[1:-1] + if name not in found: # a browser keeps the FIRST of a repeat + found[name] = html.unescape(value) + return found + + +def elements(source: str): + """Every element in document order, with comments blanked first so one can + neither hide an element nor contribute one that never renders. Offsets are + preserved, so a finding still points at the right line.""" + blanked = COMMENT_RE.sub(lambda m: " " * len(m.group(0)), source) + for m in OPEN_TAG_RE.finditer(blanked): + yield m.group("tag").lower(), attributes(m.group("attrs")), m.start() + +TAG_RE = re.compile(r"<[^>]+>") +# The value line is the only label in a node's block that STARTS with a digit: +# the name starts with a letter and the sublabel with an em dash. Anchor on the +# leading number ONLY - requiring a unit word right after it silently stops +# matching the moment the format changes to something like "9,400 - 78%", and a +# value check that attaches to nothing passes everything. +VALUE_RE = re.compile(r"^(?P\d[\d,]*(?:\.\d+)?)(?![\d,.])") + +# A node bar is a thin vertical rect inside the plot band. The legend keys are +# 16x8 and the two background rects are the full viewBox, so both fall out on +# width and on the band test. +BAR_MIN_W, BAR_MAX_W = 5.0, 14.0 +BAR_MIN_H = 2.0 # a small flow is still a node; swatches fall out on width and band +# The band's floor sits between the lowest plot row (bottom 456 in the shipped +# example) and the legend rule at y=492 — a bar is above it, a swatch below. +BAND_TOP, BAND_BOTTOM = 30.0, 480.0 +COLUMN_EPS = 1.5 # x values this close are the same column +EDGE_EPS = 2.0 # a ribbon end this close to a bar edge is attached to it + +RIBBON_WIDTH_TOL = 0.5 # px, a ribbon's two ends +NODE_TOL = 0.75 # px, in vs out at one node +COLUMN_TOL = 1.0 # px, stage against stage +SCALE_TOL = 4.0 # %, relative, drawn height vs printed value +CONTROL_TOL = 0.5 # px, a Bezier control against where it must sit +NON_VOLUME_MAX_STROKE = 2.0 # px, the widest a non-ribbon line may be drawn + +# Constructs that decouple what renders from what the markup literally says. A +# checker that reads coordinates cannot reason about any of them, so a file +# using one is refused rather than certified. Scoped to sankey files only - other +# types use transforms legitimately, and this checker never sees them. +OPAQUE_CONSTRUCTS = ( + (re.compile(r"\btransform\s*=", re.IGNORECASE), "a transform"), + (re.compile(r"\bclip-path\s*[=:]", re.IGNORECASE), "a clip path"), + (re.compile(r"\bmask\s*=", re.IGNORECASE), "a mask"), + (re.compile(r"\bdisplay\s*[:=]\s*[\"']?\s*none", re.IGNORECASE), "a hidden element"), + # `collapse` is the second spelling of hidden, and on everything except a + # table row it means exactly `hidden`. Matching one of the two was the same + # mistake as matching one spelling of zero. + (re.compile(r"\bvisibility\s*[:=]\s*[\"']?\s*(?:hidden|collapse)", re.IGNORECASE), + "a hidden element"), +) +# Self-test at import: a pattern that cannot match is a check that does not +# exist, and it fails open. That has now happened twice - an escaping slip +# turns every \b into a literal backspace, and the first version of this +# guard only looked at the six patterns named at the time, so the seventh +# inherited the bug. So the test is over the SOURCE, not over a list someone +# has to remember to extend. +if chr(8) in Path(__file__).read_text(encoding="utf-8"): + raise SystemExit( + "verify-sankey.py contains a literal backspace: a \\b was written " + "through a shell that ate an escape level, and every pattern using one " + "has silently stopped matching. Rebuild the pattern with chr(92)." + ) + + +def line_of(source: str, offset: int) -> int: + return source.count("\n", 0, offset) + 1 + + +def plain(body: str) -> str: + return html.unescape(TAG_RE.sub("", body)).strip() + + +class Bar: + __slots__ = ("x", "y", "w", "h", "offset", "value", "label", "values", "inflow", "outflow") + + def __init__(self, x: float, y: float, w: float, h: float, offset: int) -> None: + self.x, self.y, self.w, self.h, self.offset = x, y, w, h, offset + self.value: float | None = None + self.label = "" + self.values: list[tuple[float, str]] = [] + self.inflow = 0.0 + self.outflow = 0.0 + + @property + def bottom(self) -> float: + return self.y + self.h + + @property + def right(self) -> float: + return self.x + self.w + + +class Ribbon: + __slots__ = ("x0", "y0t", "y0b", "x1", "y1t", "y1b", "controls", "offset") + + def __init__(self, nums: list[float], offset: int) -> None: + # index: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 + # M x0,y0t C xm,y0t xm,y1t x1,y1t L x1,y1b C xm,y1b xm,y0b x0,y0b Z + self.x0, self.y0t = nums[0], nums[1] + self.x1, self.y1t = nums[6], nums[7] + self.y1b = nums[9] + self.y0b = nums[15] + # (control x, control y, the y it must share) for all four handles. + self.controls = ( + (nums[2], nums[3], nums[1]), # leaving, top edge + (nums[4], nums[5], nums[7]), # arriving, top edge + (nums[10], nums[11], nums[9]), # leaving target, bottom edge + (nums[12], nums[13], nums[15]), # arriving source, bottom edge + ) + self.offset = offset + + @property + def source_h(self) -> float: + return self.y0b - self.y0t + + @property + def target_h(self) -> float: + return self.y1b - self.y1t + + @property + def midline(self) -> float: + return (self.x0 + self.x1) / 2 + + +def length_px(raw: str | None) -> tuple[float | None, str | None]: + """A length in px, or a reason it cannot be read. + + The ONE place a length becomes a number, for every attribute that carries + one: stroke widths, bar widths, bar heights, bar coordinates. There were + briefly three, each having learned a different part of the job - one knew + units, one took the numeric part and threw the unit away (so a bar declared + `8em` measured 8px and every sum below was computed against a bar that is + not that size), and one demanded bare digits in a fixed attribute order. + + A relative unit is refused rather than guessed. `em` resolves against a font + and `%` and `vw` against a viewport, none of which this checker sees, so any + number it produced for them would be invented. + """ + parsed = LENGTH_RE.match((raw or "").strip()) + if not parsed: + return None, f"{raw!r} is not a length this checker can read" + unit = parsed.group("unit").lower() + if unit not in PX_PER_UNIT: + return None, ( + f"{raw!r} carries {unit!r}, which resolves against a font or viewport " + f"this checker never sees" + ) + return float(parsed.group("number")) * PX_PER_UNIT[unit], None + + +def stroke_width_px(attrs: dict) -> tuple[float | None, str | None]: + """Stroke width in px, defaulting to SVG's initial value when absent.""" + raw = attrs.get("stroke-width") + if raw is None: + return 1.0, None # SVG's initial value + width, why = length_px(raw) + return width, (f"stroke-width={why}" if why else None) + + +def fill_alpha(value: str) -> tuple[float | None, str | None]: + """Alpha of a CSS colour, or a reason it cannot be read. None means opaque. + + Written once, for every syntax, because writing it per-syntax is how this + file came to reject `rgb(235 108 54 / 0.16)` while accepting `#eb6c3600`. + """ + colour = value.strip().lower() + if not colour: + return None, None + if colour in {"none", "transparent"}: + return 0.0, None + + hexed = HEX_COLOR_RE.match(colour) + if hexed: + digits = hexed.group("digits") + if len(digits) in {3, 6}: + return None, None # no alpha channel: opaque + if len(digits) == 4: + return int(digits[3] * 2, 16) / 255, None + if len(digits) == 8: + return int(digits[6:], 16) / 255, None + return None, f"{value!r} is not a 3, 4, 6 or 8 digit hex colour" + + func = FUNC_COLOR_RE.match(colour) + if not func: + return None, None # a named colour: opaque + + args = func.group("args") + token = None + if "/" in args: # modern: fn(a b c / alpha) + token = args.split("/", 1)[1].strip() + else: + parts = [a.strip() for a in args.split(",")] + if len(parts) == 4: # legacy: fn(a, b, c, alpha) + token = parts[3] + elif len(parts) != 1 and len(parts) != 3: + return None, f"{value!r} has {len(parts)} components" + if token is None: + return None, None # no alpha given: opaque + + alpha, why = alpha_value(token) + if why: + return None, f"{value!r} has an alpha this checker cannot read" + return alpha, None + + +def alpha_value(raw: str) -> tuple[float | None, str | None]: + """A CSS as the browser resolves it, or a reason it cannot be. + + The ONE place an alpha becomes a number, because there were three and each + had learned a different half of the grammar. Two rules, and both are what + the browser does rather than what the text says: + + - a percentage is the number over 100, so `50%` and `0.5` are one value; + - the result is CLAMPED to [0, 1]. This is the half that bites. `-0.5` + renders as 0 - fully transparent - so a ribbon painted that way is a gap + in the flow while its authored text still reads as a colour with an + alpha. Taking -0.5 to mean "not zero, therefore visible" certifies a + figure with a hole in it. Above 1 clamps to 1 and is merely redundant. + """ + parsed = ALPHA_TOKEN_RE.match(raw.strip()) + if not parsed: + return None, f"{raw!r} is not a number this checker can read" + alpha = float(parsed.group("number")) + if parsed.group("pct"): + alpha /= 100 + return max(0.0, min(1.0, alpha)), None + + +def paints_a_fill(attrs: dict) -> tuple[bool | None, str | None]: + """Whether a shape paints its interior, or a reason that cannot be decided. + + A ribbon carries its volume in its FILL, so a canonical ribbon that paints + no fill occupies the arithmetic without occupying the page: conservation + still balances, every width is still correct, and the reader sees a gap + where the flow should be. Absent fill is SVG's default black, which paints. + + Returns a reason rather than raising. A malformed value is a defect in the + diagram and belongs in the findings list; letting it escape as a ValueError + turns a CI diagnosis into a traceback, which tells the contributor nothing + about what is wrong with their file. + """ + alpha, why = fill_alpha(attrs.get("fill", "")) + if why: + return None, why + if alpha == 0: + return False, None + + raw = attrs.get("fill-opacity", "").strip() + if raw: + opacity, why = alpha_value(raw) + if why: + return None, f"fill-opacity={raw!r} is not a number" + if opacity == 0: + return False, None + return True, None + + +def is_dashed(value: str) -> bool: + """Whether a stroke-dasharray actually produces a dashed line. + + "Non-empty" is not the test. An invalid pattern makes the browser drop the + property and draw a SOLID line, and so does an all-zero one - so + `stroke-dasharray="banana"`, `"0"`, `"0 0"`, `"none"` and a negative dash + all render exactly like no dash at all. Per SVG: every value must be a + non-negative length, and at least one must be positive. + """ + tokens = [t for t in re.split(r"[,\s]+", value.strip()) if t] + if not tokens: + return False + lengths = [] + for token in tokens: + parsed = LENGTH_RE.match(token) + if not parsed: + return False + if parsed.group("unit").lower() not in CSS_LENGTH_UNITS: + return False # invalid unit: the whole declaration goes + length = float(parsed.group("number")) + if length < 0: + return False + lengths.append(length) + # The list alternates dash, gap, dash, gap; an odd list is repeated once to + # make it even. So it takes a positive DASH and a positive GAP to draw a + # dashed line: "4000 0" is a 4000-long dash with nothing between, which is + # a solid line with extra steps, and "0 4" is a run of nothing at all. + if len(lengths) % 2: + lengths = lengths * 2 + dashes, gaps = lengths[0::2], lengths[1::2] + return any(d > 0 for d in dashes) and any(g > 0 for g in gaps) + + +def is_non_volume_path(attrs: dict) -> bool: + """The one non-ribbon path the reference permits: dashed, hairline, unfilled. + + Every clause is load-bearing. Unfilled alone would let a fill="none" path + with a 40px stroke render as a band and skip every check below, because a + stroke that wide is exactly what a reader reads as volume. The dash pattern + is what marks the line as a different kind of relationship rather than a + thin ribbon, and the width cap keeps it from growing into one. + """ + if attrs.get("fill", "").strip().lower() != "none": + return False + if not is_dashed(attrs.get("stroke-dasharray", "")): + return False + width, _ = stroke_width_px(attrs) + return width is not None and width <= NON_VOLUME_MAX_STROKE + + +def contained(top: float, bottom: float, bar: Bar) -> bool: + """Both edges of a ribbon end inside the bar it attaches to. + + Testing only the top edge lets a ribbon start just above the bar's foot and + run far past it while still counting as attached, which credits the volume + to the wrong node. + """ + return bar.y - EDGE_EPS <= top and bottom <= bar.bottom + EDGE_EPS + + +def parse_bars(source: str) -> list[Bar]: + """The node bars, read through the same attribute scan as everything else. + + This used to be its own regex requiring bare digits in a fixed attribute + order, which meant `width="8px"` - a perfectly ordinary SVG length, and one + `certify_readable` accepts - silently produced no bar at all, and the figure + was then rejected for having too few. Two parsers for one value is the same + fault this file has now been fixed for four times, so there is one. + + A bar that paints no fill is not a bar. `paints_a_fill` was already applied + to ribbons for exactly this reason - a shape that occupies the arithmetic + without occupying the page - and a node bar lies the same way: conservation + balances, the stage totals agree, and the reader sees a column that is not + there. + """ + bars: list[Bar] = [] + for tag, attrs, offset in elements(source): + if tag != "rect": + continue + painted, _why = paints_a_fill(attrs) + if painted is False: + continue # invisible: not a bar, and not measured + box = [length_px(attrs.get(k))[0] for k in ("x", "y", "width", "height")] + if any(v is None for v in box): + continue + x, y, w, h = box + if not (BAR_MIN_W <= w <= BAR_MAX_W) or h < BAR_MIN_H: + continue + if y < BAND_TOP or y + h > BAND_BOTTOM: + continue + bars.append(Bar(x, y, w, h, offset)) + return bars + + +def parse_ribbons(source: str, findings: list[str], name: str) -> list[Ribbon]: + ribbons: list[Ribbon] = [] + for m in PATH_RE.finditer(source): + attrs = attributes(m.group("attrs")) + d = attrs.get("d", "") + commands, nums = path_tokens(d) + if commands == RIBBON_COMMANDS and len(nums) == RIBBON_COORDS: + paints, why = paints_a_fill(attrs) + if why: + findings.append( + f"{name}:{line_of(source, m.start())}: ribbon {why} — this checker " + f"cannot tell whether it renders, and an invisible ribbon still " + f"counts in every conservation sum" + ) + continue + if not paints: + findings.append( + f"{name}:{line_of(source, m.start())}: ribbon paints no fill " + f"(fill={attrs.get('fill', '(absent)')!r}) — it would still be " + f"counted in every conservation sum while rendering nothing, so the " + f"arithmetic balances over a gap the reader can see. A ribbon " + f"carries its volume in its fill" + ) + continue + ribbons.append(Ribbon(nums, m.start())) + continue + # The reference permits exactly one non-ribbon path: a DASHED, HAIRLINE, + # unfilled stroke for a relationship that is not a volume. Match that + # description and nothing wider. "Unfilled" alone is far too loose — a + # fill="none" path with a 40px stroke renders as a band indistinguishable + # from a ribbon and would skip every check below. Width is what a reader + # reads as volume, so the exemption is capped on stroke width and + # requires the dash pattern that marks it as a different kind of line. + if is_non_volume_path(attrs): + continue + findings.append( + f"{name}:{line_of(source, m.start())}: is neither a readable ribbon " + f"(expected M/C/L/C/Z with {RIBBON_COORDS} coordinates; found " + f"{'/'.join(commands) or 'none'} with {len(nums)}) nor an unfilled path — " + f"this checker cannot confirm its width is conserved, so it must not ship " + f"unexamined. A non-volume relationship takes fill=\"none\"" + ) + return ribbons + + +def attach_values(source: str, bars: list[Bar], findings: list[str], name: str) -> None: + """Bind each bar to the value line printed in its label block. + + The shipped examples label a node three ways, one per column style: outside + the figure anchored `end` (value LEFT of the bar), centred in the gutter + anchored `middle` (value ABOVE the bar), and outside anchored `start` + (value RIGHT of the bar). Each convention gets its own tight window rather + than one loose proximity search: nearest-bar binding looks right until a + node is shorter than its own label block, at which point the value + belonging to the bar above drifts into the next bar's window and proximity + hands it to the wrong node - silently, since both then look internally + consistent. Here a value that fits more than one bar's window is a FINDING, + not a coin toss. + """ + for m in TEXT_RE.finditer(source): + attrs = attributes(m.group("attrs")) + # Through the same length parser as everything else. This was a bare + # float() wrapped in `except ValueError: continue`, so a label written + # - legal SVG, rendering in exactly the same place - + # was silently dropped, and the bar it belonged to lost its printed + # value from validation while the file still passed. + x, why_x = length_px(attrs.get("x")) + y, why_y = length_px(attrs.get("y")) + if why_x or why_y or x is None or y is None: + continue # unreadable coordinates are refused by certify_readable + body = plain(m.group("body")) + hit = VALUE_RE.match(body) + if not hit: + continue + anchor = attrs.get("text-anchor", "start") + if anchor == "end": # value line LEFT of its bar, outer column + candidates = [ + bar for bar in bars + if bar.x - 60 <= x <= bar.x - 2 and bar.y - 4 <= y <= bar.bottom + 20 + ] + elif anchor == "middle": # value line ABOVE its bar, in the stage gutter + candidates = [ + bar for bar in bars + if abs(x - (bar.x + bar.w / 2)) <= 8 and bar.y - 40 <= y <= bar.y - 2 + ] + else: # value line RIGHT of its bar, outer column + candidates = [ + bar for bar in bars + if bar.right + 2 <= x <= bar.right + 60 and bar.y - 4 <= y <= bar.bottom + 20 + ] + if not candidates: + continue # legend copy and annotations may open with a digit + if len(candidates) > 1: + findings.append( + f"{name}:{line_of(source, m.start())}: the value line {body!r} sits " + f"where it could label {len(candidates)} different node bars — a " + f"reader has the same ambiguity, so the label must move until it " + f"belongs to exactly one node" + ) + continue + host = candidates[0] + # Record EVERY value-shaped line that binds, not just the first. Taking + # the first and moving on lets a second, contradictory number sit in the + # same block: the checker validates the one it read, and the reader is + # shown the one it ignored. + host.values.append((float(hit.group("num").replace(",", "")), body)) + if host.value is None: + host.value = host.values[0][0] + host.label = body + + +# === CERTIFY ONLY WHAT CAN BE READ ========================================== +# +# Every guard above this line is a BLOCKLIST, and each was patched three times: +# stroke-width but not fill, geometry but not containers, `0` but not `0%`, +# `4foo` but not `4cap`. A blocklist fails OPEN - whatever it has not been told +# about is certified - and SVG has far more ways to render differently than any +# list will hold. An audit of this file found seventeen, every one of which the +# checks below certified as clean: `` erases every ribbon +# under it, `` holds geometry that never draws, `systemLanguage` suppresses +# an element, a nested `` rescales the figure, a stylesheet can set `d` +# outright, and a single-quoted attribute is invisible to a scan built for +# double quotes. +# +# So the sets below are ALLOWLISTS and they fail CLOSED: an element, attribute +# or CSS property this checker was never taught is a REFUSAL, not a pass. +# Staleness then costs a rejection naming the thing it did not know - loud, and +# one line to fix - rather than a silent certification of a figure that lies. +# +# Derived from the three shipped variants, plus the few constructs the type +# reference documents but the shipped example does not happen to use. +ALLOWED_TAGS = frozenset(( + # page chrome + "html", "head", "meta", "title", "link", "style", "body", + "div", "h1", "h3", "p", "span", + # the figure + "svg", "g", "desc", "defs", "pattern", "circle", "rect", "path", "line", "text", +)) +SVG_TAGS = frozenset(( + "svg", "g", "desc", "title", "defs", "pattern", "circle", "rect", "path", + "line", "text", +)) +ALLOWED_ATTRS = frozenset(( + "aria-labelledby", "charset", "class", "content", "cx", "cy", "d", "fill", + "font-family", "font-size", "font-style", "font-weight", "height", "href", "id", "lang", + "letter-spacing", "name", "opacity", "patternunits", "r", "rel", "role", + "rx", "stroke", "stroke-width", "text-anchor", "viewbox", "width", + "x", "x1", "x2", "xmlns", "y", "y1", "y2", + # documented in references/type-sankey.md, unused by the shipped example + "fill-opacity", "stroke-dasharray", "stroke-opacity", "stroke-linecap", + # Named so the generic "attribute I do not read" message does not fire on + # top of the two dedicated ones, which say something more useful. + "style", "transform", "clip-path", "mask", "visibility", +)) +# A CSS rule reaches the figure when its selector names an SVG element or matches +# everything. Such a rule may declare only properties that cannot move, resize, +# repaint or hide geometry: the shipped files need `svg { width; min-width; +# display }` and the `*` reset, and nothing else has a reason to. +SVG_SELECTOR_TOKENS = frozenset( + set(SVG_TAGS) | {"g", "tspan", "use", "image", "polygon", "polyline", "ellipse"} +) +SAFE_ON_SVG_PROPS = frozenset(( + "box-sizing", "margin", "padding", "width", "min-width", "max-width", "display", +)) +SELECTOR_TOKEN_RE = re.compile(r"[a-z][\w-]*", re.IGNORECASE) +RULE_RE = re.compile(r"(?P[^{}]+)\{(?P[^{}]*)\}") +CSS_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL) +DEFS_BLOCK_RE = re.compile(r"]*>(?P.*?)", re.IGNORECASE | re.DOTALL) +GEOMETRIC_ATTRS = ("x", "y", "width", "height", "x1", "y1", "x2", "y2", "cx", "cy", "r") +LEGEND_SWATCH = (16.0, 8.0) + + + + +def certify_readable(name: str, source: str) -> list[str]: + """Refuse anything this checker cannot read, before it reads anything else. + + An empty result means every construct in the file is one the checks below + genuinely understand - which is the only condition under which their silence + is evidence that the figure is honest rather than evidence that it is opaque. + """ + findings: list[str] = [] + + def refuse(offset: int, why: str) -> None: + findings.append(f"{name}:{line_of(source, offset)}: {why}") + + svg_roots = 0 + for tag, attrs, offset in elements(source): + if tag not in ALLOWED_TAGS: + refuse(offset, f"uses <{tag}>, which this checker does not read. An element " + f"it cannot read may paint, hide, clone or move a ribbon while " + f"every coordinate here stays correct, so refusing is the " + f"honest answer — teach it what <{tag}> does before allowing it") + continue + for attribute in sorted(attrs): + if attribute not in ALLOWED_ATTRS: + refuse(offset, f"<{tag}> carries {attribute!r}, which this checker does " + f"not read. Conditional processing, filters, clones and " + f"paint servers all arrive as an attribute and all change " + f"what renders without changing a number here") + if tag in SVG_TAGS and ("style" in attrs or "class" in attrs): + carried = " and ".join(k for k in ("style", "class") if k in attrs) + refuse(offset, f"<{tag}> carries {carried}, which can set a paint property " + f"from outside the attributes this checker reads — and on a " + f"container it reaches every ribbon below it. A fill decides " + f"whether a shape renders at all, and stroke width is apparent " + f"volume: under stroke-width:30 a ribbon draws 30px wider than " + f"the fill it is measured by. Paint belongs in attributes on " + f"the element it paints, so what renders is what is measured") + if tag == "svg": + svg_roots += 1 + if svg_roots > 1: + refuse(offset, "nests a second , which establishes a viewport of its " + "own — a transform written without the word, rescaling " + "every ribbon under it while the coordinates stay as " + "authored") + # Every coordinate and size, read through the one length parser. These + # used to be read where they were needed - with a bare float() in one + # place, wrapped in `except ValueError: continue` - so `` + # is legal SVG that renders exactly where it says, and the label was + # silently dropped from validation instead. A value this checker cannot + # read is a check it is not performing, and the file still passed. + full_bleed_size = (attrs.get("width", "").strip() == "100%" + and attrs.get("height", "").strip() == "100%") + for geometric in GEOMETRIC_ATTRS: + raw_value = attrs.get(geometric) + if raw_value is None: + continue + if full_bleed_size and geometric in ("width", "height"): + continue # the paper, sized to its parent + _value, why_geom = length_px(raw_value) + if why_geom: + refuse(offset, f"<{tag}> has {geometric}={raw_value!r}, which this checker " + f"cannot resolve: {why_geom}. Every check below is " + f"arithmetic on these numbers") + + # A paint value this checker cannot read is refused rather than assumed + # either way. Assuming it paints counts a shape that may not be there; + # assuming it does not drops a shape that is. `parse_bars` and + # `parse_ribbons` below can therefore both treat "not False" as painted, + # because an undecidable fill never reaches them. + if tag in SVG_TAGS: + _painted, why_paint = paints_a_fill(attrs) + if why_paint: + refuse(offset, f"<{tag}> has a paint this checker cannot read: {why_paint}") + + # The full-bleed rect is the paper and its texture. It carries no volume + # and nothing is measured against it, so a pattern fill there is the one + # place a paint server says nothing about the flow. + full_bleed = (attrs.get("width", "").strip() == "100%" + and attrs.get("height", "").strip() == "100%") + for paint in (() if full_bleed else ("fill", "stroke")): + if attrs.get(paint, "").strip().lower().startswith("url("): + refuse(offset, f"<{tag}> paints its {paint} from a paint server, which can " + f"resolve to a fully transparent gradient or to nothing at " + f"all. This checker cannot follow the reference, so it " + f"cannot say whether the shape reaches the page") + if tag == "rect": + raw_w = (attrs.get("width") or "").strip() + raw_h = (attrs.get("height") or "").strip() + if raw_w == "100%" and raw_h == "100%": + continue # the paper, or its texture + width, why_w = length_px(raw_w) + height, why_h = length_px(raw_h) + # y decides WHICH shape a rect may claim to be — a swatch lives in + # the legend region and a bar in the plot band — so an unreadable y + # is refused along with an unreadable size. Absent y is SVG's 0. + y, why_y = length_px((attrs.get("y") or "0").strip() or "0") + if why_w or why_h or why_y: + refuse(offset, f"draws a whose size or position cannot be read: " + f"{why_w or why_h or why_y}. A bar height IS the volume it " + f"claims, so a value this checker has to guess at is a sum " + f"it cannot stand behind") + continue + if (width, height) == LEGEND_SWATCH and y is not None and y >= BAND_BOTTOM: + continue # a legend key, in the legend + if (width is not None and height is not None and y is not None + and BAR_MIN_W <= width <= BAR_MAX_W and height >= BAR_MIN_H + and BAND_TOP <= y and y + height <= BAND_BOTTOM): + continue # a node bar; parse_bars reads it + refuse(offset, f"draws a {raw_w or '?'}x{raw_h or '?'} at y={y:g}, " + f"which is neither a node bar, a legend swatch, nor the " + f"full-bleed paper — a bar lives inside the plot band and a " + f"swatch below it. A rect that is none of those is ink laid " + f"over the figure, and painted last it covers whatever was " + f"just measured") + if tag == "line": + width_px, why = stroke_width_px(attrs) + if why or width_px is None: + refuse(offset, f" has {why}") + elif width_px > NON_VOLUME_MAX_STROKE: + refuse(offset, f"draws a {width_px:g}px wide, which reads as a band " + f"of volume. A rule here is a hairline; volume is a ribbon") + + # is a definitions block: its contents render only where something + # references them. The shipped files use it for the dot pattern and nothing + # else, so a ribbon or a node bar parked in there draws nowhere while every + # sum below still counts it — conservation balancing over a shape that is + # not on the page. + for block in DEFS_BLOCK_RE.finditer(source): + for tag, _, offset in elements(block.group("body")): + if tag in ("path", "rect", "line"): + refuse(block.start("body") + offset, + f"puts a <{tag}> inside , where it defines a shape rather " + f"than drawing one. Every check below would still count it, so " + f"the figure would balance over geometry no reader can see") + + for block in STYLE_BLOCK_RE.finditer(source): + body = CSS_COMMENT_RE.sub(" ", block.group("css")) + for rule in RULE_RE.finditer(body): + selector = rule.group("selector").strip() + tokens = {t.lower() for t in SELECTOR_TOKEN_RE.findall(selector)} + if not (tokens & SVG_SELECTOR_TOKENS or "*" in selector): + continue # page chrome only + for declaration in rule.group("body").split(";"): + if ":" not in declaration: + continue + prop = declaration.split(":", 1)[0].strip().lower() + if prop and prop not in SAFE_ON_SVG_PROPS: + refuse(block.start(), + f"a stylesheet rule for {selector!r} sets {prop!r}. A rule that " + f"reaches the figure may set only " + f"{', '.join(sorted(SAFE_ON_SVG_PROPS))} — anything else " + f"repaints, resizes, moves or hides geometry from a place no " + f"attribute records") + return findings + + +def check(path: Path) -> list[str]: + source = path.read_text(encoding="utf-8") + name = path.name + findings: list[str] = [] + + # Everything below reads raw coordinates out of the markup. That is only + # sound while the markup means what it says, so refuse the file outright if + # it uses anything that moves, clips or hides geometry at render time — + # a transform, a clip, a mask, or a hidden element. Each of those lets every + # invariant here be broken while the numbers this checker reads stay + # perfectly correct, and reimplementing SVG rendering to catch that would be + # reimplementing a browser. Refusing to certify is the honest answer, and it + # costs nothing: no shipped sankey uses any of them. + for pattern, what in OPAQUE_CONSTRUCTS: + hit = pattern.search(source) + if hit: + findings.append( + f"{name}:{line_of(source, hit.start())}: uses {what}, which changes " + f"what renders without changing the coordinates this checker reads — " + f"every check below could pass on a figure that draws something else. " + f"Lay sankey geometry out in absolute coordinates instead" + ) + # `opacity` belongs with those, but it cannot be a pattern in that table: + # zero has more spellings than a regex should be asked to hold, and the + # previous one matched `0` and `0.0` while `.0`, `+0`, `0e5`, `0%` and any + # negative all sailed past it into a figure with an invisible ribbon still + # counted as flow. Same lesson as the lengths and the colours, for the + # third time: parse the value the browser parses, do not match the forms + # this file happens to use. Any element, because a node bar or a label + # turned invisible is the same class of lie as a ribbon. + for m in ANY_ELEMENT_RE.finditer(source): + raw = attributes(m.group("attrs")).get("opacity") + if raw is None: + continue + opacity, why = alpha_value(raw) + if why: + findings.append( + f"{name}:{line_of(source, m.start())}: <{m.group('tag').lower()}> has " + f"opacity={raw!r}, which is not a number this checker can read" + ) + elif opacity == 0: + findings.append( + f"{name}:{line_of(source, m.start())}: <{m.group('tag').lower()}> is " + f"fully transparent (opacity={raw!r}), so it renders as nothing while " + f"every coordinate this checker reads stays correct — an invisible " + f"ribbon still balances, and an invisible node bar still measures" + ) + # And everything the checker has never been taught. This runs after the + # constructs above so that a transform is reported as a transform, not as an + # unrecognised attribute, and before everything below because none of it + # means anything on a file whose markup cannot be read. + findings.extend(certify_readable(name, source)) + if findings: + return findings + + # And the same principle stated as an invariant rather than a blocklist, + # because the indirect routes do not enumerate: a class, a bare element + # selector, a rule on a container, an inherited attribute, a media query. + # Patching them one at a time is an infinite ladder. So `stroke-width` may + # appear ONLY as an attribute on the element it applies to. Declared in a + # stylesheet or on an / ancestor it reaches geometry this checker + # measures without appearing anywhere it reads, and stroke width is + # apparent volume. + for block in STYLE_BLOCK_RE.finditer(source): + for prop in INDIRECT_PAINT_PROPS: + hit = re.search(rf"(? sets " + f"{prop}={attrs[prop]!r}, which every descendant inherits — including " + f"ribbons this checker measures by their fill. A ribbon with no fill " + f"of its own would render whatever this says. Set it per element" + ) + # Which leaves the direct route: a stroke-width attribute is legitimate — the + # legend swatches carry a 1px key stroke — but a fat one paints outside the + # fill this checker measures, so a 30px stroke renders a ribbon 30px wider + # than the volume it is credited with. A separator is a hairline; anything + # thicker is being used as width, and width is the encoding. + for m in STYLED_GEOMETRY_RE.finditer(source): + attrs = attributes(m.group("attrs")) + if "stroke-width" not in attrs: + continue + width, why = stroke_width_px(attrs) + if why: + findings.append( + f"{name}:{line_of(source, m.start())}: <{m.group('tag').lower()}> {why} — " + f"and an unread stroke width is an unknown amount of apparent volume" + ) + elif width is not None and width > NON_VOLUME_MAX_STROKE: + findings.append( + f"{name}:{line_of(source, m.start())}: <{m.group('tag').lower()}> has " + f"stroke-width={attrs['stroke-width']!r} = {width:g}px, which paints " + f"outside the fill this checker measures — the shape renders wider than " + f"the volume it is credited with. A separator is a hairline; keep it at " + f"or under {NON_VOLUME_MAX_STROKE:g}px" + ) + if findings: + return findings + + bars = parse_bars(source) + ribbons = parse_ribbons(source, findings, name) + # A half-parsed figure is the worst outcome this checker can have: every + # check below is per-node or per-ribbon, so a file with bars but no ribbons + # (or the reverse) would pass in silence having verified nothing. Both sides + # have to be present, and saying so is not the same as saying it is clean. + if bars or ribbons: + if len(bars) < 3: + findings.append( + f"{name}: found {len(ribbons)} ribbon(s) but only {len(bars)} node bar(s) — " + f"a node bar is a rect {BAR_MIN_W:g}-{BAR_MAX_W:g}px wide and at least " + f"{BAR_MIN_H:g}px tall inside the plot band. Nothing here could be checked, " + f"which is not the same as nothing being wrong" + ) + if len(ribbons) < 2: + findings.append( + f"{name}: found {len(bars)} node bar(s) but only {len(ribbons)} readable " + f"ribbon(s) — a sankey with no ribbons carries no flow, and every " + f"conservation check below would pass on it vacuously" + ) + if len(bars) < 3 or len(ribbons) < 2: + return findings + attach_values(source, bars, findings, name) + + # 5. A ribbon must meet its node bars square-on. + # + # For a cubic, B'(1) = 3(P3 - P2): the arrival tangent is set by the last + # control point and nothing else. Park both controls on the corridor + # midline and that tangent is exactly horizontal, so the ribbon plugs into + # the bar flat. Put one on the target's x instead and P2 coincides with + # P3, the first derivative vanishes, and the visible approach falls to + # B''(1) - measured at 133 degrees off horizontal for a ribbon in this + # figure. It renders as a band skewed into its own node, and no other + # check here would notice, because the endpoints are still correct. + for r in ribbons: + for index, (cx, cy, expected_y) in enumerate(r.controls, 1): + if abs(cx - r.midline) > CONTROL_TOL: + findings.append( + f"{name}:{line_of(source, r.offset)}: ribbon control point " + f"{index} sits at x={cx:g} instead of the corridor midline " + f"{r.midline:g} — the arrival tangent degenerates and the " + f"ribbon meets its node bar at a slant" + ) + if abs(cy - expected_y) > CONTROL_TOL: + findings.append( + f"{name}:{line_of(source, r.offset)}: ribbon control point " + f"{index} sits at y={cy:g} but its edge ends at {expected_y:g} — " + f"an edge whose control leaves its own y bulges away from the " + f"width the ribbon claims to carry" + ) + + # 3. A ribbon must not change width along its run. + for r in ribbons: + drift = abs(r.source_h - r.target_h) + if drift > RIBBON_WIDTH_TOL: + findings.append( + f"{name}:{line_of(source, r.offset)}: ribbon leaves at " + f"{r.source_h:.2f}px and arrives at {r.target_h:.2f}px — a " + f"{drift:.2f}px change in width invents or destroys volume; " + f"width must be constant along a ribbon" + ) + + # Attach every ribbon end to the bar it touches. An end that lands on no bar + # is reported rather than dropped: an unattached ribbon is invisible to the + # conservation sums, so silently skipping it would let the exact defect this + # checker exists for slip through as a clean run. + for r in ribbons: + ends = {"leaves": False, "arrives": False} + for bar in bars: + if abs(r.x0 - bar.right) <= EDGE_EPS and contained(r.y0t, r.y0b, bar): + bar.outflow += r.source_h + ends["leaves"] = True + if abs(r.x1 - bar.x) <= EDGE_EPS and contained(r.y1t, r.y1b, bar): + bar.inflow += r.target_h + ends["arrives"] = True + loose = [side for side, hit in ends.items() if not hit] + if loose: + findings.append( + f"{name}:{line_of(source, r.offset)}: ribbon " + f"({r.x0:g},{r.y0t:g}) → ({r.x1:g},{r.y1t:g}) {' and '.join(loose)} at no " + f"node bar — a ribbon that touches nothing is counted in no node's " + f"balance, so it must not ship unattached" + ) + + # 1. Conservation, read at each node. + for bar in bars: + who = bar.label or f"{bar.w:g}x{bar.h:g} at {bar.x:g},{bar.y:g}" + if bar.inflow > 0 and bar.outflow > 0: + gap = abs(bar.inflow - bar.outflow) + if gap > NODE_TOL: + findings.append( + f"{name}:{line_of(source, bar.offset)}: node {who!r} takes in " + f"{bar.inflow:.2f}px and puts out {bar.outflow:.2f}px — {gap:.2f}px " + f"is unaccounted for. Flow that leaves the story needs its own " + f"named node in the final column, not a quiet disappearance" + ) + for side, total in (("inflow", bar.inflow), ("outflow", bar.outflow)): + if total > 0 and abs(total - bar.h) > NODE_TOL: + findings.append( + f"{name}:{line_of(source, bar.offset)}: node {who!r} is {bar.h:.2f}px " + f"tall but its {side} sums to {total:.2f}px — the bar must be exactly " + f"as tall as the flow through it" + ) + + # 2. Every stage carries the same total. + columns: dict[float, float] = {} + for bar in bars: + key = next((k for k in columns if abs(k - bar.x) <= COLUMN_EPS), bar.x) + columns[key] = columns.get(key, 0.0) + bar.h + if len(columns) >= 2: + totals = sorted(columns.items()) + reference = totals[0][1] + for x, total in totals[1:]: + if abs(total - reference) > COLUMN_TOL: + findings.append( + f"{name}: the stage at x={x:g} carries {total:.2f}px against " + f"{reference:.2f}px at x={totals[0][0]:g} — every stage of a sankey " + f"shows the same whole, so a stage that drops volume has to say " + f"where it went" + ) + + # 4. One scale, printed value to drawn height. + for bar in bars: + if len(bar.values) > 1: + shown = " and ".join(repr(text) for _, text in bar.values[:3]) + findings.append( + f"{name}:{line_of(source, bar.offset)}: node bar at {bar.x:g},{bar.y:g} " + f"has more than one value printed beside it — {shown}. Only one can be " + f"the height this bar draws, and a reader has no way to tell which. A " + f"sublabel opens with an em dash so it is not read as a second value" + ) + for bar in bars: + if not bar.value: + findings.append( + f"{name}:{line_of(source, bar.offset)}: node bar at " + f"{bar.x:g},{bar.y:g} has no readable value beside it — its drawn " + f"height is then compared against nothing, and the fidelity check " + f"below silently shrinks to the bars that do parse" + ) + scaled = [(bar, bar.h / bar.value) for bar in bars if bar.value] + if len(scaled) >= 3: + reference = sorted(s for _, s in scaled)[len(scaled) // 2] # median + for bar, scale in scaled: + relative = (scale - reference) / reference * 100 + if abs(relative) > SCALE_TOL: + findings.append( + f"{name}:{line_of(source, bar.offset)}: node {bar.label!r} draws " + f"{bar.h:.2f}px for {bar.value:g}, which is {relative:+.1f}% off the " + f"scale the rest of the figure uses. Width is the only encoding; " + f"resize the node" + ) + return findings + + +def geometry(path: Path) -> list[str]: + """The shape of a figure, stripped of every token that a skin changes.""" + source = path.read_text(encoding="utf-8") + shape = [ + f"bar {b.x:g} {b.y:g} {b.w:g} {b.h:g}" + for b in sorted(parse_bars(source), key=lambda b: (b.x, b.y)) + ] + ribbons = sorted( + parse_ribbons(source, [], path.name), + key=lambda r: (r.x0, r.y0t), + ) + shape += [ + f"ribbon {r.x0:g} {r.y0t:g} {r.y0b:g} {r.x1:g} {r.y1t:g} {r.y1b:g}" + for r in ribbons + ] + return shape + + +VARIANTS = ("example-sankey.html", "example-sankey-dark.html", "example-sankey-full.html") +REFERENCE_DOC = ROOT / "skills/diagram-design/references/type-sankey.md" +SVG_BLOCK_RE = re.compile(r"```svg\n(?P.*?)```", re.DOTALL) +ELEMENT_RE = re.compile( + r"<(?Prect|text|path)\b(?P[^>]*?)(?:/>|>(?P.*?))", + re.DOTALL | re.IGNORECASE, +) +WHITESPACE_RE = re.compile(r"\s+") + + +def squashed(text: str) -> str: + return WHITESPACE_RE.sub(" ", text).strip() + + +def check_reference_excerpt(findings: list[str]) -> None: + """The type reference's element pattern must be a real excerpt. + + A code sample in a doc is the one piece of geometry nothing renders, so it + drifts silently the first time the layout moves - and it is precisely what + a contributor copies when building the next diagram of this type. Requiring + it to appear verbatim in the shipped example makes the sample as trustworthy + as the figure it came from. + """ + if not REFERENCE_DOC.exists(): + findings.append(f"missing type reference: {REFERENCE_DOC.name}") + return + light = ASSET_DIR / VARIANTS[0] + if not light.exists(): + return + haystack = squashed(light.read_text(encoding="utf-8")) + for block in SVG_BLOCK_RE.finditer(REFERENCE_DOC.read_text(encoding="utf-8")): + for element in ELEMENT_RE.finditer(block.group("body")): + needle = squashed(element.group(0)) + if needle not in haystack: + findings.append( + f"{REFERENCE_DOC.name}: the element pattern shows " + f"{needle[:72]!r}, which is not in {VARIANTS[0]} — a sample " + f"nothing renders drifts the moment the layout moves, and it " + f"is what the next contributor will copy. Paste a real excerpt" + ) + + +def check_variants(paths: list[Path]) -> list[str]: + if len(paths) < 2: + return [] + findings: list[str] = [] + # Compare against the light minimal, not whatever the glob sorted first, so + # the diagnostic names the canonical file a reader would go and look at. + base = next((p for p in paths if p.name == VARIANTS[0]), paths[0]) + reference = geometry(base) + for path in paths: + if path == base: + continue + other = geometry(path) + if other == reference: + continue + extra = [line for line in other if line not in reference] + missing = [line for line in reference if line not in other] + detail = (extra + missing)[:3] + findings.append( + f"{path.name}: geometry differs from {base.name} " + f"({len(extra)} added, {len(missing)} missing; e.g. {'; '.join(detail)}) — " + f"the variants are one figure in three skins, so only tokens may differ. " + f"Regenerate them from a single model instead of editing each by hand" + ) + return findings + + +def targets(args: argparse.Namespace) -> list[Path]: + if args.all: + return sorted(ASSET_DIR.glob("example-sankey*.html")) + return [Path(p) for p in args.paths] + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Verify sankey flow conservation, ribbon width and value fidelity." + ) + parser.add_argument("paths", nargs="*", help="HTML files to check") + parser.add_argument("--all", action="store_true", help="check every shipped sankey example") + args = parser.parse_args() + if not args.all and not args.paths: + parser.print_help() + return 2 + + paths = targets(args) + # A glob that matches nothing is the quietest way for a gate to stop + # gating: it reads no files, finds no faults, and exits 0. --all knows + # exactly which three files the type ships, so it can tell the difference + # between "clean" and "not there". + if args.all: + missing = [name for name in VARIANTS if not (ASSET_DIR / name).exists()] + if missing: + print( + f"error: --all expects the three shipped variants in " + f"{ASSET_DIR.relative_to(ROOT).as_posix()}; missing " + f"{', '.join(missing)}", + file=sys.stderr, + ) + return 2 + elif not paths: + parser.print_help() + return 2 + + findings: list[str] = [] + for path in paths: + if not path.exists(): + print(f"error: {path} does not exist", file=sys.stderr) + return 2 + findings.extend(check(path)) + if args.all: + findings.extend(check_variants(paths)) + check_reference_excerpt(findings) + + for finding in findings: + print(finding) + if findings: + print(f"\n{len(findings)} sankey finding(s) across {len(paths)} file(s).") + return 1 + print( + f"OK sankey: {len(paths)} file(s), flow conserved at every node, stages carry " + f"equal totals, ribbons hold their width" + + (", variants share one geometry" if args.all else "") + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify-screenshot-freshness.py b/scripts/verify-screenshot-freshness.py index b6a0392..686752d 100644 --- a/scripts/verify-screenshot-freshness.py +++ b/scripts/verify-screenshot-freshness.py @@ -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 diff --git a/scripts/verify-semantic-motion.py b/scripts/verify-semantic-motion.py index 5c6450c..c4b3af9 100644 --- a/scripts/verify-semantic-motion.py +++ b/scripts/verify-semantic-motion.py @@ -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") diff --git a/skills/diagram-design/SKILL.md b/skills/diagram-design/SKILL.md index 8aeb2ec..8e1262a 100644 --- a/skills/diagram-design/SKILL.md +++ b/skills/diagram-design/SKILL.md @@ -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 | diff --git a/skills/diagram-design/assets/example-polar-dark.html b/skills/diagram-design/assets/example-polar-dark.html new file mode 100644 index 0000000..fcb1944 --- /dev/null +++ b/skills/diagram-design/assets/example-polar-dark.html @@ -0,0 +1,116 @@ + + + + + + Request demand by UTC window · Polar chart + + + + +
+

Polar · Diagram Design

+

Request demand by UTC window

+ + + Request demand by UTC window + Polar chart on a 0–100% scale identifying 12–15 UTC as the 100% peak, with the other seven UTC windows proceeding clockwise. + + + % OF DAILY PEAK · 0–100 + + + + + + + + + + + + + + + + + 20 + 40 + 60 + 80 + 100 + 0 + + + + + 00–03 + 32 + + + + + 03–06 + 18 + + + + + 06–09 + 24 + + + + + 09–12 + 58 + + + + + 12–15 + 100 + + + + + 15–18 + 82 + + + + + 18–21 + 76 + + + + + 21–24 + 45 + + + Illustrative normalized workload profile + +
+ + diff --git a/skills/diagram-design/assets/example-polar-full.html b/skills/diagram-design/assets/example-polar-full.html new file mode 100644 index 0000000..5c016a6 --- /dev/null +++ b/skills/diagram-design/assets/example-polar-full.html @@ -0,0 +1,147 @@ + + + + + + Request demand by UTC window · Polar chart + + + + +
+
+

Polar · Diagram Design

+

Request demand by UTC window

+

One normalized workload series across eight UTC windows, with radius showing percent of the daily peak.

+
+ +
+ + Request demand by UTC window + Polar chart on a 0–100% scale identifying 12–15 UTC as the 100% peak, with the other seven UTC windows proceeding clockwise. + + + % OF DAILY PEAK · 0–100 + + + + + + + + + + + + + + + + + 20 + 40 + 60 + 80 + 100 + 0 + + + + + 00–03 + 32 + + + + + 03–06 + 18 + + + + + 06–09 + 24 + + + + + 09–12 + 58 + + + + + 12–15 + 100 + + + + + 15–18 + 82 + + + + + 18–21 + 76 + + + + + 21–24 + 45 + + + Illustrative normalized workload profile + +
+ +
+
+

Peak · 12–15 UTC · 100%

+

The focal ray reaches the shared 100% outer ring.

+
+
+

Quietest · 03–06 UTC · 18%

+

The shortest ray remains visibly proportional to the zero baseline.

+
+
+

Evening tail · 18–21 UTC · 76%

+

Demand stays elevated into the final quarter of the clockwise cycle.

+
+
+ + +
+ + diff --git a/skills/diagram-design/assets/example-polar.html b/skills/diagram-design/assets/example-polar.html new file mode 100644 index 0000000..6e0799a --- /dev/null +++ b/skills/diagram-design/assets/example-polar.html @@ -0,0 +1,116 @@ + + + + + + Request demand by UTC window · Polar chart + + + + +
+

Polar · Diagram Design

+

Request demand by UTC window

+ + + Request demand by UTC window + Polar chart on a 0–100% scale identifying 12–15 UTC as the 100% peak, with the other seven UTC windows proceeding clockwise. + + + % OF DAILY PEAK · 0–100 + + + + + + + + + + + + + + + + + 20 + 40 + 60 + 80 + 100 + 0 + + + + + 00–03 + 32 + + + + + 03–06 + 18 + + + + + 06–09 + 24 + + + + + 09–12 + 58 + + + + + 12–15 + 100 + + + + + 15–18 + 82 + + + + + 18–21 + 76 + + + + + 21–24 + 45 + + + Illustrative normalized workload profile + +
+ + diff --git a/skills/diagram-design/assets/index.html b/skills/diagram-design/assets/index.html index c3a0431..afdb58d 100644 --- a/skills/diagram-design/assets/index.html +++ b/skills/diagram-design/assets/index.html @@ -240,107 +240,110 @@ +
diff --git a/skills/diagram-design/assets/template-full.html b/skills/diagram-design/assets/template-full.html index 840a9ce..b01d75a 100644 --- a/skills/diagram-design/assets/template-full.html +++ b/skills/diagram-design/assets/template-full.html @@ -176,7 +176,7 @@
- + [Diagram title] [One sentence describing what the diagram shows] diff --git a/skills/diagram-design/references/doctor.md b/skills/diagram-design/references/doctor.md new file mode 100644 index 0000000..a2d4b37 --- /dev/null +++ b/skills/diagram-design/references/doctor.md @@ -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)` + +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 +``` diff --git a/skills/diagram-design/references/onboarding.md b/skills/diagram-design/references/onboarding.md index 86267d2..96e08e6 100644 --- a/skills/diagram-design/references/onboarding.md +++ b/skills/diagram-design/references/onboarding.md @@ -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`). --- diff --git a/skills/diagram-design/references/semantic-patterns.md b/skills/diagram-design/references/semantic-patterns.md index d44598c..b12e39d 100644 --- a/skills/diagram-design/references/semantic-patterns.md +++ b/skills/diagram-design/references/semantic-patterns.md @@ -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. diff --git a/skills/diagram-design/references/type-high-level.md b/skills/diagram-design/references/type-high-level.md index 009158f..5c483a0 100644 --- a/skills/diagram-design/references/type-high-level.md +++ b/skills/diagram-design/references/type-high-level.md @@ -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`). --- diff --git a/skills/diagram-design/references/type-polar.md b/skills/diagram-design/references/type-polar.md new file mode 100644 index 0000000..bed1d03 --- /dev/null +++ b/skills/diagram-design/references/type-polar.md @@ -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 ``. Its descendants are limited to ``, ``, ``, ``, ``, ``, and ``; 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.