From 5f1b6ddc66036554dd68701798bd12aa5e3ef3f9 Mon Sep 17 00:00:00 2001
From: 0xDarkMatter <143511969+0xDarkMatter@users.noreply.github.com>
Date: Wed, 19 Aug 2026 15:31:48 +1000
Subject: [PATCH] feat(line): add slopegraph variant for change between two
states (#106)
---
.claude-plugin/plugin.json | 2 +-
.codex-plugin/plugin.json | 2 +-
.github/workflows/ci.yml | 11 +
CONTRIBUTING.md | 9 +-
scripts/test-verify-slopegraph.py | 673 ++++++++++++++++
scripts/verify-slopegraph.py | 754 ++++++++++++++++++
skills/diagram-design/SKILL.md | 2 +-
.../assets/example-slopegraph-dark.html | 119 +++
.../assets/example-slopegraph-full.html | 149 ++++
.../assets/example-slopegraph.html | 119 +++
skills/diagram-design/assets/index.html | 3 +
skills/diagram-design/references/type-line.md | 104 +++
12 files changed, 1943 insertions(+), 4 deletions(-)
create mode 100644 scripts/test-verify-slopegraph.py
create mode 100644 scripts/verify-slopegraph.py
create mode 100644 skills/diagram-design/assets/example-slopegraph-dark.html
create mode 100644 skills/diagram-design/assets/example-slopegraph-full.html
create mode 100644 skills/diagram-design/assets/example-slopegraph.html
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index e3e27a2..383133c 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, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
- "version": "2.5.5",
+ "version": "2.5.6",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
index f1b01a1..aedf73f 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, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
- "version": "2.5.5",
+ "version": "2.5.6",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 88d0911..ad48b79 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -206,6 +206,15 @@ jobs:
if: always()
id: dumbbell_tests
run: python scripts/test-verify-dumbbell.py
+ - name: Verify slopegraph shared scale and endpoint values
+ if: always()
+ id: slopegraph
+ run: python scripts/verify-slopegraph.py --all
+
+ - name: Verify slopegraph checker
+ if: always()
+ id: slopegraph_tests
+ run: python scripts/test-verify-slopegraph.py
- name: Verify generated icon assets
if: always()
@@ -245,4 +254,6 @@ jobs:
echo "| Treemap Checker | ${{ steps.treemap_tests.outcome == 'success' && '✅ Passed' || (steps.treemap_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Dumbbell Domain & Contrast | ${{ steps.dumbbell.outcome == 'success' && '✅ Passed' || (steps.dumbbell.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Dumbbell Checker | ${{ steps.dumbbell_tests.outcome == 'success' && '✅ Passed' || (steps.dumbbell_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Slopegraph Shared Scale | ${{ steps.slopegraph.outcome == 'success' && '✅ Passed' || (steps.slopegraph.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Slopegraph Checker | ${{ steps.slopegraph_tests.outcome == 'success' && '✅ Passed' || (steps.slopegraph_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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0d8b6e6..b921b64 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -60,6 +60,8 @@ The helper refuses to run if the Claude and Codex versions already differ. If an
| Treemap checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-treemap.py` |
| Dumbbell domain resolves finitely and its marks clear 3:1 | `python3 scripts/verify-dumbbell.py` |
| Dumbbell checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-dumbbell.py` |
+| Slopegraph axes share one scale and every endpoint matches its printed value | `python3 scripts/verify-slopegraph.py --all` |
+| Slopegraph checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-slopegraph.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.
@@ -86,7 +88,11 @@ python3 scripts/test-plugin-package.py \
&& python3 scripts/verify-geometry.py --all \
&& python3 scripts/test-verify-geometry.py \
&& python3 scripts/verify-treemap.py --all \
- && python3 scripts/test-verify-treemap.py \n && python3 scripts/verify-dumbbell.py \n && python3 scripts/test-verify-dumbbell.py
+ && python3 scripts/test-verify-treemap.py \
+ && python3 scripts/verify-dumbbell.py \
+ && python3 scripts/test-verify-dumbbell.py \
+ && python3 scripts/verify-slopegraph.py --all \
+ && python3 scripts/test-verify-slopegraph.py
```
### If a gate fails
@@ -94,6 +100,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-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/scripts/test-verify-slopegraph.py b/scripts/test-verify-slopegraph.py
new file mode 100644
index 0000000..9f2d241
--- /dev/null
+++ b/scripts/test-verify-slopegraph.py
@@ -0,0 +1,673 @@
+#!/usr/bin/env python3
+"""Adversarial cases for verify-slopegraph.py, both polarities.
+
+Every case is named for exactly what it asserts. A case called "unlabelled
+sliver does not disable the check" once proved only that the *labelled* elements
+stayed checked, which made a real gap look covered for days - so a name here
+that overclaims is itself a defect.
+
+The negative half matters as much as the positive: a checker that fires on an
+honest indexed slopegraph, or on the other shipped example types, gets widened
+or switched off, and then it guards nothing.
+
+Fixtures live in a per-process temporary directory. They used to be written to
+fixed paths under the repository root and unconditionally unlinked, which would
+overwrite and then delete a real file that happened to share a fixture name, and
+raced when two runs overlapped. Two cases at the end hold that fix in place.
+
+Exit: 0 all pass, 1 any failure.
+"""
+
+from __future__ import annotations
+
+import re
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT / "scripts"))
+
+verify = __import__("verify-slopegraph")
+
+SHIPPED = ROOT / "skills/diagram-design/assets/example-slopegraph.html"
+
+HEAD = """
+
t
+\n"
+
+# The bound state captions every slopegraph must carry.
+CAPTIONS = (
+ ' BEFORE\n'
+ ' AFTER\n'
+)
+
+# y = 420 - (v - 100) * 380/450, the shared scale the shipped example uses:
+# p95 milliseconds over a 100-550 domain.
+PX = 380.0 / 450.0
+
+
+def y(value: float) -> float:
+ return round(420 - (value - 100) * PX, 1)
+
+
+def series(name: str, frm: float, to: float, y1=None, y2=None,
+ x1: float = 320, x2: float = 680, omit: str = "") -> str:
+ parts = ['data-series="%s"' % name]
+ if omit != "data-from":
+ parts.append('data-from="%s"' % frm)
+ if omit != "data-to":
+ parts.append('data-to="%s"' % to)
+ parts.append('x1="%g"' % x1)
+ if omit != "y1":
+ parts.append('y1="%g"' % (y(frm) if y1 is None else y1))
+ parts.append('x2="%g"' % x2)
+ if omit != "y2":
+ parts.append('y2="%g"' % (y(to) if y2 is None else y2))
+ return " \n" % " ".join(parts)
+
+
+def labels(name: str, frm: float, to: float, shown_from=None, shown_to=None,
+ skip: str = "", skip_name: str = "", y_from=None, y_to=None) -> str:
+ """The value and name label pair for each end of one series.
+
+ y_from / y_to place the labels independently of the value scale, so a
+ fixture that moves the geometry can move its labels with it. A fixture
+ whose labels stay behind reports a real row defect that has nothing to do
+ with what the case is testing.
+ """
+ out = ""
+ for end, value, shown, row in (("from", frm, shown_from, y_from),
+ ("to", to, shown_to, y_to)):
+ if skip == end:
+ continue
+ text = value if shown is None else shown
+ baseline = (y(value) if row is None else row) + 3.5
+ out += (' %s\n'
+ % (name, end, 304 if end == "from" else 696, baseline, text))
+ if skip_name != end:
+ out += (' %s\n'
+ % (name, end, 272 if end == "from" else 728, baseline, name))
+ return out
+
+
+def document(*blocks: str) -> str:
+ return HEAD + CAPTIONS + "".join(blocks) + TAIL
+
+
+def honest(rows) -> str:
+ return document(honest_rows_block(rows))
+
+
+def honest_rows_block(rows=None) -> str:
+ """The series+labels block for a correct figure, without the wrapper."""
+ body = ""
+ for name, frm, to in (ROWS if rows is None else rows):
+ body += series(name, frm, to) + labels(name, frm, to)
+ return body
+
+
+ROWS = [("Search", 512, 208), ("Catalog", 376, 164), ("Checkout", 291, 143),
+ ("Auth", 154, 121), ("Recommender", 238, 431)]
+
+
+class Harness:
+ def __init__(self) -> None:
+ self.failures = 0
+ self.count = 0
+ # A private temp dir per instance: fixture names are meaningful to the
+ # checker (it keys detection off the filename) but their directory is
+ # not, so there is no reason to put them anywhere a real file lives.
+ self.dir = Path(tempfile.mkdtemp(prefix="slopegraph-fixtures-"))
+
+ def close(self) -> None:
+ shutil.rmtree(self.dir, ignore_errors=True)
+
+ def path_for(self, name: str) -> Path:
+ return self.dir / name
+
+ def run(self, source: str, name: str = "example-slopegraph-fixture.html") -> list:
+ path = self.path_for(name)
+ path.write_text(source, encoding="utf-8")
+ try:
+ return verify.check(path)
+ finally:
+ path.unlink()
+
+ def expect_clean(self, label: str, source: str, name: str | None = None) -> None:
+ self.count += 1
+ found = self.run(source, name) if name else self.run(source)
+ if found:
+ self.failures += 1
+ print("FAIL %s" % label)
+ for item in found:
+ print(" unexpected: %s" % item)
+ else:
+ print("ok %s" % label)
+
+ def expect_finding(self, label: str, source: str, pattern: str,
+ name: str | None = None) -> None:
+ self.count += 1
+ found = self.run(source, name) if name else self.run(source)
+ if not found:
+ self.failures += 1
+ print("FAIL %s\n expected a finding, got none" % label)
+ return
+ if not any(re.search(pattern, item) for item in found):
+ self.failures += 1
+ print("FAIL %s\n no finding matched %r" % (label, pattern))
+ for item in found:
+ print(" got: %s" % item)
+ return
+ print("ok %s" % label)
+
+ def expect_only_one(self, label: str, source: str, pattern: str) -> None:
+ """A single defect must produce a single finding, not a cascade."""
+ self.count += 1
+ found = self.run(source)
+ if len(found) == 1 and re.search(pattern, found[0]):
+ print("ok %s" % label)
+ return
+ self.failures += 1
+ print("FAIL %s\n expected exactly one finding matching %r, got %d"
+ % (label, pattern, len(found)))
+ for item in found:
+ print(" got: %s" % item)
+
+ def expect_out_of_scope(self, label: str, source: str, name: str) -> None:
+ """Not merely finding-free - genuinely outside this checker's scope.
+
+ expect_clean cannot tell the two apart, so four cases named "out of
+ scope" were only ever proving "no findings", which a detected-and-clean
+ file also satisfies.
+ """
+ self.count += 1
+ path = self.path_for(name)
+ path.write_text(source, encoding="utf-8")
+ try:
+ detected = verify.looks_like_slopegraph(path, source)
+ found = verify.check(path)
+ finally:
+ path.unlink()
+ if detected or found:
+ self.failures += 1
+ print("FAIL %s\n detected=%s findings=%d"
+ % (label, detected, len(found)))
+ return
+ print("ok %s" % label)
+
+ def check(self, label: str, condition: bool, detail: str = "") -> None:
+ self.count += 1
+ if condition:
+ print("ok %s" % label)
+ return
+ self.failures += 1
+ print("FAIL %s%s" % (label, ("\n " + detail) if detail else ""))
+
+
+def main() -> int:
+ h = Harness()
+ try:
+ return run_cases(h)
+ finally:
+ h.close()
+
+
+def run_cases(h: Harness) -> int:
+ # ── Negative half: honest figures must stay silent ────────────────────
+ h.expect_clean("an honest slopegraph reports nothing", honest(ROWS))
+
+ h.expect_clean(
+ "the shipped light example reports nothing",
+ SHIPPED.read_text(encoding="utf-8"), name="example-slopegraph.html",
+ )
+
+ # Two series holding identical values must land on identical y. This is the
+ # case the draft got wrong, and the honest rendering of it must pass.
+ h.expect_clean(
+ "two series with identical values drawn at identical y is legal",
+ honest([("Search", 512, 208), ("Mirror", 512, 208), ("Auth", 154, 121)]),
+ )
+
+ h.expect_clean(
+ "a correct indexed slopegraph (every series starts at the index base) "
+ "is not rejected",
+ honest([("api", 100, 143), ("web", 100, 118), ("worker", 100, 196)]),
+ )
+
+ # Coordinates ship rounded to 0.1px, and an author may reasonably round to
+ # whole pixels instead. Neither may read as a defect.
+ for label, quantum in (("one decimal", 1), ("whole pixels", 0)):
+ rounded = ""
+ for name, frm, to in ROWS:
+ rounded += series(name, frm, to, y1=round(y(frm), quantum),
+ y2=round(y(to), quantum)) + labels(name, frm, to)
+ h.expect_clean("coordinates rounded to %s stay within tolerance" % label,
+ document(rounded))
+
+ # The rotated value-axis caption carries a transform and must not be caught:
+ # it is not verified geometry and not a bound label.
+ h.expect_clean(
+ "the rotated value-axis caption's transform is not reported",
+ document(' '
+ "P95 RESPONSE TIME, MS\n" + honest_rows_block()),
+ )
+
+ # `text-transform` is not `transform`.
+ h.expect_clean(
+ "a CSS text-transform declaration is not read as a transform",
+ HEAD + "\n"
+ + CAPTIONS + honest_rows_block() + TAIL,
+ )
+
+ # The detector must not drag the other example types into scope.
+ for other in ("example-line.html", "example-bar.html", "example-treemap.html",
+ "example-scatter.html"):
+ path = ROOT / "skills/diagram-design/assets" / other
+ if path.exists():
+ h.expect_out_of_scope(
+ "%s is not detected as a slopegraph at all" % other,
+ path.read_text(encoding="utf-8"), other)
+
+ # ── Positive half: each lie must be named as itself ───────────────────
+
+ # 1. Jitter. One point moved to free up label space.
+ jittered = ""
+ for name, frm, to in ROWS:
+ shift = 3.0 if name == "Search" else 0.0
+ jittered += series(name, frm, to, y2=y(to) - shift) + labels(name, frm, to)
+ h.expect_only_one(
+ "a single endpoint nudged 3px reports as one point, not as a bad axis",
+ document(jittered), r"'Search' draws its to endpoint.*off by 3\.0 px",
+ )
+
+ # The specific shape of the draft's bug: two series at identical values
+ # pulled apart so their labels clear each other.
+ collided = ""
+ for name, frm, to in [("Search", 512, 208), ("Mirror", 512, 208),
+ ("Catalog", 376, 164), ("Checkout", 291, 143),
+ ("Auth", 154, 121)]:
+ if name == "Search":
+ collided += series(name, frm, to, y1=y(frm) - 3, y2=y(to) - 3)
+ else:
+ collided += series(name, frm, to)
+ collided += labels(name, frm, to)
+ h.expect_finding(
+ "an equal-valued series pulled 3px clear of its twin's labels is reported",
+ document(collided), r"'Search' draws its (from|to) endpoint",
+ )
+
+ # An indexed slopegraph with one endpoint wrong. A whole-set fit called this
+ # clean: with three points and one 3px error Theil-Sen has no outlier budget,
+ # and the contaminated slope passed within 1px of every point.
+ indexed_lie = (series("api", 100, 118) + labels("api", 100, 118)
+ + series("web", 100, 143) + labels("web", 100, 143)
+ + series("worker", 100, 196, y2=y(196) + 3)
+ + labels("worker", 100, 196))
+ h.expect_finding(
+ "an indexed slopegraph with one endpoint 3px wrong is reported",
+ document(indexed_lie), r"'worker' draws its to endpoint",
+ )
+
+ # 2. Dual scale, and a shared scale with a shifted origin.
+ dual = ""
+ for name, frm, to in ROWS:
+ dual += series(name, frm, to, y2=round(420 - (420 - y(to)) * 0.8, 1)) \
+ + labels(name, frm, to)
+ h.expect_only_one(
+ "a right axis compressed to 80% reports as a scale mismatch and stops there",
+ document(dual), r"the two axes are not on the same scale",
+ )
+
+ shifted = ""
+ for name, frm, to in ROWS:
+ shifted += (series(name, frm, to, y2=y(to) - 12)
+ + labels(name, frm, to, y_to=y(to) - 12))
+ h.expect_only_one(
+ "a right axis shifted 12px at an identical slope reports as an origin offset",
+ document(shifted), r"share a scale but not an origin",
+ )
+
+ # 3. Transforms move the rendered mark away from the checked coordinates.
+ h.expect_finding(
+ "a transform on a series line is reported",
+ document(series("Search", 512, 208).replace(
+ " transform over the series is reported",
+ document(' \n' + honest_rows_block()
+ + " \n"),
+ r"carries an ancestor /