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 + + t + Slopegraph fixture. +""" +TAIL = "\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 / transform", + ) + + h.expect_finding( + "a CSS transform declaration is reported", + HEAD + "\n" + + CAPTIONS + honest_rows_block() + TAIL, + r"a CSS `transform` declaration", + ) + + # 4. The printed value must be one complete token that matches metadata. + h.expect_finding( + "a printed value that contradicts the declared one is reported", + document(series("Search", 512, 208) + + labels("Search", 512, 208, shown_to="180") + + honest_rows_block([r for r in ROWS if r[0] != "Search"])), + r"'Search' prints '180' at its to endpoint but declares 208", + ) + + h.expect_finding( + "a thousands separator that changes the value is reported", + document(series("Search", 512, 208) + + labels("Search", 512, 208, shown_from="512,000") + + honest_rows_block([r for r in ROWS if r[0] != "Search"])), + r"prints '512,000' at its from endpoint but declares 512", + ) + + h.expect_finding( + "a label carrying two numeric tokens is reported, not half-read", + document(series("Search", 512, 208) + + labels("Search", 512, 208, shown_from="512 was 600") + + honest_rows_block([r for r in ROWS if r[0] != "Search"])), + r"prints more than one numeric token", + ) + + h.expect_clean( + "a unit suffix on a printed value is accepted", + document("".join(series(n, f, t) + + labels(n, f, t, shown_from="%dms" % f, + shown_to="%dms" % t) + for n, f, t in ROWS)), + ) + + h.expect_finding( + "a series missing one endpoint value label is reported", + document(series("Search", 512, 208) + + labels("Search", 512, 208, skip="to") + + honest_rows_block([r for r in ROWS if r[0] != "Search"])), + r"'Search' prints no to endpoint value", + ) + + # 5. Captions and names must be bound to what they describe. + swapped_text = document(honest_rows_block()).replace( + ">BEFORE", ">@@T@@", 1).replace( + ">AFTER", ">BEFORE", 1).replace( + ">@@T@@", ">AFTER", 1) + h.expect_finding( + "swapping only the visible caption text is reported", + swapped_text, r"state caption reads 'AFTER' but declares data-state='BEFORE'", + ) + + swapped_x = document(honest_rows_block()).replace( + 'data-state="BEFORE" x="320"', 'data-state="BEFORE" x="@@"', 1).replace( + 'data-state="AFTER" x="680"', 'data-state="AFTER" x="320"', 1).replace( + 'x="@@"', 'x="680"', 1) + h.expect_finding( + "swapping the caption x values is reported", + swapped_x, r"state caption \('BEFORE'\) is drawn at x=680", + ) + + h.expect_finding( + "a caption with no data-state is reported", + document(honest_rows_block()).replace(' data-state="BEFORE"', "", 1), + r"has no data-state", + ) + + h.expect_finding( + "a missing state caption is reported", + HEAD + ' ' + "BEFORE\n" + honest_rows_block() + TAIL, + r"no 'to' state caption", + ) + + h.expect_finding( + "two captions reading the same thing is reported", + document(honest_rows_block()).replace( + 'data-state="AFTER" x="680" y="440">AFTER<', + 'data-state="BEFORE" x="680" y="440">BEFORE<', 1), + r"both state captions read 'BEFORE'", + ) + + h.expect_finding( + "two series names swapped between rows is reported", + document(honest_rows_block()).replace( + 'data-role="name" x="272" y="%g">Search<' % (y(512) + 3.5), + 'data-role="name" x="272" y="%g">Catalog<' % (y(512) + 3.5), 1), + r"name label bound to series 'Search' reads 'Catalog'", + ) + + h.expect_finding( + "a name label drawn on another series' row is reported", + document(honest_rows_block()).replace( + 'data-series="Auth" data-end="from" data-role="name" x="272" y="%g"' + % (y(154) + 3.5), + 'data-series="Auth" data-end="from" data-role="name" x="272" y="%g"' + % (y(512) + 3.5), 1), + r"name label for series 'Auth' is drawn at y=.*nearer 'Search'", + ) + + h.expect_finding( + "a series with no name label is reported", + document(series("Search", 512, 208) + + labels("Search", 512, 208, skip_name="from") + + honest_rows_block([r for r in ROWS if r[0] != "Search"])), + r"'Search' has no from name label", + ) + + h.expect_finding( + "a second label for the same endpoint is reported", + document(honest_rows_block() + + ' 999' + "\n"), + r"a second from value label for series 'Search'", + ) + + h.expect_finding( + "an endpoint label for a series with no line is reported", + document(honest_rows_block() + + ' 300' + "\n"), + r"names series 'Ghost', which no line declares", + ) + + # 6. Fail closed, and read what is actually there. + h.expect_finding( + "a file named example-slopegraph with no series is reported, not passed", + document(' \n'), + r"presents as a slopegraph but declares 0 verifiable series", + name="example-slopegraph-empty.html", + ) + + h.expect_finding( + "a file whose desc says slopegraph but declares no series is reported", + document(' \n'), + r"presents as a slopegraph but declares 0 verifiable series", + name="figure.html", + ) + + h.expect_finding( + "a slopegraph declared only in a desc past 4000 chars is still detected", + "" + + 't' + + "Slopegraph of latency with no declared series." + + '', + r"presents as a slopegraph but declares 0 verifiable series", + name="figure.html", + ) + + for omitted in ("data-from", "data-to", "y1", "y2"): + h.expect_finding( + "a series line missing %s is reported rather than dropped" % omitted, + document(series("Search", 512, 208, omit=omitted) + + labels("Search", 512, 208) + + honest_rows_block([r for r in ROWS if r[0] != "Search"])), + r"missing %s" % omitted, + ) + + h.expect_finding( + "a declared value that is not a finite number is reported rather than dropped", + document(' \n' + + honest_rows_block([r for r in ROWS if r[0] != "Search"])), + r"not a finite number", + ) + + h.expect_finding( + "a NaN coordinate is reported, not silently accepted", + document(' \n' + + honest_rows_block()), + r"not a finite number", + ) + + h.expect_clean( + "a commented-out series line is ignored, not read as data", + document(honest_rows_block() + + ' \n'), + ) + + h.expect_clean( + "an honest slopegraph written with single-quoted attributes is parsed", + HEAD + CAPTIONS + "".join( + " \n" + % (n, f, t, y(f), y(t)) + labels(n, f, t) for n, f, t in ROWS) + TAIL, + ) + + h.expect_finding( + "a single-quoted series line with a nudged endpoint is reported", + HEAD + CAPTIONS + "".join( + " \n" + % (n, f, t, y(f), y(t) - (3.0 if n == "Search" else 0.0)) + + labels(n, f, t) for n, f, t in ROWS) + TAIL, + r"'Search' draws its to endpoint", + ) + + h.expect_finding( + "a declaring data-series whose attributes cannot be parsed is reported", + document(honest_rows_block() + + " \n"), + r"declares data-series but its attributes could not be parsed", + ) + + h.expect_clean( + "axis rules and legend swatches without data-series are skipped silently", + document(honest_rows_block() + + ' \n' + ' \n'), + ) + + for label in ("left", "right"): + rows = "" + for name, frm, to in ROWS: + stray = name == "Search" + rows += series( + name, frm, to, + x1=300 if (stray and label == "left") else 320, + x2=640 if (stray and label == "right") else 680, + ) + labels(name, frm, to) + h.expect_finding( + "a series that does not share the %s axis position is reported" % label, + document(rows), r"series do not share one x[12]", + ) + + h.expect_finding( + "a figure where every series holds one value on each axis is reported " + "as unverifiable", + honest([("api", 100, 120), ("web", 100, 120)]), + r"neither axis has two distinct values", + ) + + sci = "" + for n, f, t in (("a", 512, 208), ("b", 376, 164), ("c", 291, 143), + ("d", 154, 121)): + sci += (series(n, "%ge0" % f, "%ge0" % t, y1=y(f), y2=y(t)) + + labels(n, f, t, shown_from="%ge0" % f, shown_to="%ge0" % t)) + h.expect_clean( + "scientific-notation labels are parsed, not truncated to the mantissa", + document(sci), + ) + + # Real geometry, values offset by a billion: the two axes share one + # transform, and only an intercept compared at value zero would call them + # shifted. Mapping the raw billions through the fixture scale instead put + # every point at y = -8.4e8, where the fit is degenerate and the case + # passed without testing anything. + base = 1000000000 + billions = "" + for n, f, t in ROWS: + billions += (series(n, base + f, base + t, y1=y(f), y2=y(t)) + + labels(n, base + f, base + t, y_from=y(f), y_to=y(t))) + h.expect_clean( + "a shared scale on billion-magnitude values is not reported as shifted", + document(billions), + ) + + # ── The fixture-isolation fix, held in place ────────────────────────── + sentinel = ROOT / "example-slopegraph-fixture.html" + existed = sentinel.exists() + if not existed: + sentinel.write_text("KEEP ME\n", encoding="utf-8") + try: + h.run(honest(ROWS)) + h.run(honest(ROWS), name="example-slopegraph-fixture.html") + h.check( + "a pre-existing file sharing a fixture name is never touched", + sentinel.exists() and sentinel.read_text(encoding="utf-8") == "KEEP ME\n", + "%s was overwritten or deleted by the harness" % sentinel, + ) + finally: + if not existed and sentinel.exists(): + sentinel.unlink() + + other = Harness() + try: + h.check( + "two harnesses use different directories, so parallel runs cannot collide", + other.dir != h.dir and not str(other.dir).startswith(str(ROOT)), + "dirs %s and %s" % (h.dir, other.dir), + ) + finally: + other.close() + + h.check( + "fixtures are written outside the repository", + not str(h.dir).startswith(str(ROOT)), + "fixture dir %s is inside %s" % (h.dir, ROOT), + ) + + print() + if h.failures: + print("%d of %d case(s) failed." % (h.failures, h.count)) + return 1 + print("OK slopegraph checker: %d case(s), both polarities" % h.count) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify-slopegraph.py b/scripts/verify-slopegraph.py new file mode 100644 index 0000000..66c0e3c --- /dev/null +++ b/scripts/verify-slopegraph.py @@ -0,0 +1,754 @@ +#!/usr/bin/env python3 +"""Verify that a slopegraph's drawn slopes match the values it prints. + +A slopegraph makes exactly one claim: **both axes carry the same scale**, so the +angle of a line is a rate of change and two angles are comparable. Break that +claim and nothing errors - the figure renders, every label is present, and the +lie is in the geometry. `lint-skin.py` reads colors and fonts, `verify-geometry.py` +reads label masks against later-painted nodes, and neither compares a drawn +endpoint against the number sitting beside it. + +Six invariants, each of which has shipped broken in a draft of this type: + +1. SHARED SCALE - the left and right axes must map value to y through the same + linear transform. Both halves are checked, slope *and* origin: a second axis + that is rescaled makes every slope wrong by a factor, and one that is merely + shifted makes every slope wrong by a constant. Checking the slope alone would + pass the shifted case, which is the easier mistake to make. + +2. NO JITTER - each declared value must sit where the shared scale puts it. The + draft this checker was written against nudged two series apart by 3px because + their labels collided at identical values; both then read as different + numbers. Crowded endpoint labels are data, not a coordinate problem. + +3. UNTRANSFORMED GEOMETRY - the coordinates read here are raw attributes, so any + `transform` on verified geometry, on its labels, or on an ancestor moves the + rendered mark away from the number this checker validated. A single + `transform="translate(0 80)"` on one series line slid its endpoint 80px and + every check still passed. Transforms are rejected rather than resolved: a + partial implementation of the SVG transform stack is worse than an honest + refusal, because it looks like coverage. + +4. COMPLETE PRINTED VALUES - the whole visible numeric token must match the + declared one. Matching only the first fragment read the label "512,000" as + 512 and agreed with metadata that said 512. + +5. LABELS BOUND TO MEANING - state captions belong to a named axis, and each + series name belongs to a series and an end. Unbound, the two captions can be + swapped - reversing the reading of the entire figure - or two names exchanged + between rows, with every geometric check still green. + +6. FAIL CLOSED - a file that presents as a slopegraph but yields nothing + parseable is a finding, never a pass. A checker that reports OK because it + found nothing to compare is the bug, not the gate. `verify-treemap.py` + returned early on `len(cells) < 3` and that is precisely how an undersized + cell went unverified. + +The basis for geometry is the `data-from` / `data-to` pair each series line +declares, never the rendered text. Deriving the basis from text is what let a +treemap cell with no label drop silently out of the checked set; here a series +whose label is missing or restyled stays in the set and its missing label is +itself reported. + +WHAT THIS DOES NOT CHECK, deliberately: + +- **Direction.** An axis whose y grows with value is accepted. That is correct + for a rank slopegraph, where rank 1 belongs at the top, and this checker has no + way to tell an intended inversion from a mistaken one. +- **Absolute truth.** Every check here is internal consistency. If every label + and every declared value is wrong by the same constant, the figure is + self-consistent and passes; nothing in the file could reveal otherwise. The + source line is where the domain is stated to a reader, and prose is not parsed. +- **Curvature below tolerance.** A value-to-y map that bends by less than + RESIDUAL_TOLERANCE at every point is indistinguishable from a straight one. + +Usage: + python3 scripts/verify-slopegraph.py --all + python3 scripts/verify-slopegraph.py skills/diagram-design/assets/example-slopegraph.html + +Exit: 0 clean, 1 findings, 2 usage. +""" + +from __future__ import annotations + +import argparse +import html +import math +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +ASSET_DIR = ROOT / "skills/diagram-design/assets" + +LINE_RE = re.compile(r"[^>]*?)/?>", re.IGNORECASE) +TEXT_RE = re.compile(r"[^>]*)>(?P.*?)", re.IGNORECASE | re.DOTALL) +COMMENT_RE = re.compile(r"", re.DOTALL) +NAMED_RE = re.compile(r"<(?Ptitle|desc)\b[^>]*>(?P.*?)", + re.IGNORECASE | re.DOTALL) +GROUP_OPEN_RE = re.compile(r"<(?:g|svg)\b(?P[^>]*?)(?P/?)>", re.IGNORECASE) +GROUP_CLOSE_RE = re.compile(r"", re.IGNORECASE) +STYLE_RE = re.compile(r"]*>(?P.*?)", re.IGNORECASE | re.DOTALL) +# `transform:` but not `text-transform:`. The full-editorial template uses the +# latter, so an unguarded pattern would report every editorial variant. +CSS_TRANSFORM_RE = re.compile(r"(?]+>") +# The COMPLETE numeric token an author may print: sign, comma-grouped thousands, +# decimals, leading-dot decimals, exponents. Matching only `-?\d+(\.\d+)?` read +# "1e3" as 1 and "512,000" as 512, so a mangled label agreed with its metadata. +NUMBER_RE = re.compile( + r"[-+]?(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?" +) +DIGIT_RE = re.compile(r"\d") + +# Both quote styles. Matching only double quotes made a single-quoted series +# line invisible: the detector below sees `data-series` in the raw source either +# way, but the attribute parser returned nothing, so the line was dropped from +# the verified set without a word - a file carrying a 400px lie reported clean. +ATTR_RE = re.compile( + r"""(?P[\w:-]+)\s*=\s*(?P["'])(?P.*?)(?P=quote)""", + re.DOTALL, +) +DECLARES_SERIES_RE = re.compile(r"\bdata-series\s*=", re.IGNORECASE) + +# Coordinates ship rounded to one decimal, so a point can sit 0.05px from its +# true position honestly; an author rounding to whole pixels can sit 0.5px off. +# 1.0px clears both and still catches the smallest dishonest nudge worth making +# (the draft's was 3px). In data units at the shipped 0.844 px-per-ms scale that +# is 1.2ms of slack on values running 121-512 - about a quarter of one percent, +# below the precision the labels themselves claim. +RESIDUAL_TOLERANCE = 1.0 # px, per endpoint, against the shared scale +ORIGIN_TOLERANCE = 0.5 # px, between the two axes at mid-domain +SCALE_TOLERANCE = 1.0 # px, worst-case slope disagreement across the range +VALUE_TOLERANCE = 0.001 # printed label vs declared attribute +CAPTION_TOLERANCE = 0.5 # px, state caption x vs the axis it names +ROW_TOLERANCE = 0.5 # px, slack before a label counts as another row + + +class Series: + __slots__ = ("name", "frm", "to", "x1", "y1", "x2", "y2", "offset") + + def __init__(self, name, frm, to, x1, y1, x2, y2, offset): + self.name = name + self.frm, self.to = frm, to + self.x1, self.y1 = x1, y1 + self.x2, self.y2 = x2, y2 + self.offset = offset + + def value(self, end): + return self.frm if end == "from" else self.to + + def endpoint_y(self, end): + return self.y1 if end == "from" else self.y2 + + def axis_x(self, end): + return self.x1 if end == "from" else self.x2 + + +def line_of(source: str, offset: int) -> int: + return source.count("\n", 0, offset) + 1 + + +def blank_comments(source: str) -> str: + """Comments out, length and line numbers preserved. + + Markup inside a comment is not rendered, so treating it as data reports a + commented-out old draft as a live defect. Replacing each comment with spaces + of the same length keeps every later offset and line number honest. + """ + return COMMENT_RE.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), source) + + +def attrs_of(raw: str) -> dict: + return {m.group("name"): m.group("value") for m in ATTR_RE.finditer(raw)} + + +def plain(body: str) -> str: + return html.unescape(TAG_RE.sub("", body)).strip() + + +def number(value): + """A finite float from a complete numeric token, or None. + + `float("nan")` succeeds and then every `abs(x) > tolerance` comparison is + False, so a NaN coordinate silently satisfied every check in the file. Any + non-finite value is treated as unreadable instead. + """ + if value is None: + return None + try: + parsed = float(str(value).replace(",", "")) + except (TypeError, ValueError): + return None + return parsed if math.isfinite(parsed) else None + + +def printed_number(body: str): + """(value, reason) for a label's visible text. + + Returns a value only when the text carries exactly one complete numeric + token. Trailing units are fine ("512ms"); a second number, or digits the + token did not consume, is ambiguous and reported rather than guessed at. + """ + match = NUMBER_RE.search(body) + if match is None: + return None, "prints no number" + outside = body[:match.start()] + body[match.end():] + if DIGIT_RE.search(outside): + return None, "prints more than one numeric token" + value = number(match.group()) + if value is None: + return None, "prints a number this checker cannot read" + return value, None + + +def median(values: list) -> float: + ordered = sorted(values) + middle = len(ordered) // 2 + if len(ordered) % 2: + return ordered[middle] + return (ordered[middle - 1] + ordered[middle]) / 2.0 + + +def fit(points: list) -> tuple: + """Robust (slope, intercept) for y = slope * value + intercept. + + Theil-Sen - the median of all pairwise slopes - rather than least squares, so + that one dishonest point does not drag the line it is being measured against. + Returns (None, None) when every value is the same, which is legitimate on an + indexed axis and handled by the caller. + """ + slopes = [ + (yb - ya) / (vb - va) + for index, (va, ya) in enumerate(points) + for vb, yb in points[index + 1:] + if vb != va + ] + if not slopes: + return None, None + slope = median(slopes) + return slope, median([y - slope * v for v, y in points]) + + +def outliers(points: list, tolerance: float) -> list: + """Points that disagree with the line their PEERS describe. + + Leave-one-out, because measuring a point against a fit that includes it lets + it hide inside its own influence. Theil-Sen's breakdown point is around 29%, + which is zero outliers on a three-point axis: an indexed three-series figure + with one endpoint 3px wrong produced a whole-set fit that called all three + points clean, because the contaminated slope passed within 1px of every one + of them. Excluding the point under test removes that escape. + + Returns [(index, drawn, expected)]. Needs four points, so each fit is made + from at least three. + """ + if len(points) < 4: + return [] + found = [] + for index, (value, drawn) in enumerate(points): + peers = points[:index] + points[index + 1:] + slope, intercept = fit(peers) + if slope is None: + continue + expected = slope * value + intercept + if abs(drawn - expected) > tolerance: + found.append((index, drawn, expected)) + return found + + +def named_text(source: str) -> str: + """The accessible name and description, where a figure says what it is.""" + return " ".join(plain(m.group("body")) for m in NAMED_RE.finditer(source)).casefold() + + +def looks_like_slopegraph(path: Path, source: str) -> bool: + """Does this file present itself as a slopegraph? + + Deliberately generous. Anything that claims the type in its name, its + accessible description, or its markup is held to the contract even if it + declares no parseable series - that combination is the fail-closed case, not + a pass. The whole document is searched: a 4000-character window missed a file + whose only declaration sat behind a long stylesheet. + """ + if path.name.startswith("example-slopegraph"): + return True + if DECLARES_SERIES_RE.search(source): + return True + described = named_text(source) + return "slopegraph" in described or "slope graph" in described + + +def transformed_spans(source: str) -> list: + """Offset ranges enclosed by a / that carries a transform. + + Element-level transforms are easy to see; an ancestor's is not, and shifts + everything inside it identically - which is exactly the change that leaves + all internal consistency intact while moving every mark on the page. + """ + events = [] + for match in GROUP_OPEN_RE.finditer(source): + if match.group("selfclose"): + continue + events.append((match.start(), 0, "transform" in attrs_of(match.group("attrs")))) + for match in GROUP_CLOSE_RE.finditer(source): + events.append((match.start(), 1, False)) + events.sort() + stack, spans = [], [] + for position, kind, transformed in events: + if kind == 0: + stack.append((position, transformed)) + elif stack: + start, was_transformed = stack.pop() + if was_transformed: + spans.append((start, position)) + # An unclosed transformed group covers everything after it. + for start, was_transformed in stack: + if was_transformed: + spans.append((start, len(source))) + return spans + + +def parse_series(source: str, findings: list, name: str) -> list: + """Series lines, with anything unparseable reported rather than dropped.""" + series = [] + for match in LINE_RE.finditer(source): + raw = match.group("attrs") + attrs = attrs_of(raw) + label = attrs.get("data-series") + if label is None: + # A with no data-series is scenery - an axis rule, a legend + # swatch - and skipping it is correct. But one whose raw text DOES + # declare data-series and still parsed to nothing is markup this + # checker cannot read, and dropping it silently is how a lie ships. + if DECLARES_SERIES_RE.search(raw): + findings.append( + "%s:%d: a declares data-series but its attributes could " + "not be parsed — the checker will not silently skip markup it " + "cannot read. Use plain double-quoted attributes" + % (name, line_of(source, match.start())) + ) + continue + missing = [key for key in ("data-from", "data-to", "x1", "y1", "x2", "y2") + if key not in attrs] + if missing: + findings.append( + "%s:%d: series %r declares data-series but is missing %s — a series " + "line must state both values and both endpoints or it cannot be verified" + % (name, line_of(source, match.start()), label, ", ".join(missing)) + ) + continue + parsed = [number(attrs[key]) + for key in ("data-from", "data-to", "x1", "y1", "x2", "y2")] + if any(value is None for value in parsed): + findings.append( + "%s:%d: series %r has a value or coordinate that is not a finite " + "number — cannot verify its slope" + % (name, line_of(source, match.start()), label) + ) + continue + series.append(Series(label, parsed[0], parsed[1], parsed[2], parsed[3], + parsed[4], parsed[5], match.start())) + return series + + +def check_transforms(source: str, findings: list, name: str) -> None: + """No transform may move verified geometry or a bound label.""" + spans = transformed_spans(source) + + def enclosed(offset): + return any(start <= offset <= end for start, end in spans) + + def report(offset, what, how): + findings.append( + "%s:%d: %s carries %s — this checker validates raw x/y attributes, so " + "a transform moves the rendered mark away from the number it was " + "checked against. Bake the offset into the coordinates instead" + % (name, line_of(source, offset), what, how) + ) + + for match in LINE_RE.finditer(source): + attrs = attrs_of(match.group("attrs")) + if "data-series" not in attrs: + continue + what = "series %r" % attrs["data-series"] + if "transform" in attrs: + report(match.start(), what, "transform=%r" % attrs["transform"]) + elif enclosed(match.start()): + report(match.start(), what, "an ancestor / transform") + + for match in TEXT_RE.finditer(source): + attrs = attrs_of(match.group("attrs")) + if "data-series" not in attrs and "data-axis" not in attrs: + continue + what = "a bound label (%s)" % plain(match.group("body"))[:20] + if "transform" in attrs: + report(match.start(), what, "transform=%r" % attrs["transform"]) + elif enclosed(match.start()): + report(match.start(), what, "an ancestor / transform") + + for match in STYLE_RE.finditer(source): + found = CSS_TRANSFORM_RE.search(match.group("body")) + if found: + findings.append( + "%s:%d: a CSS `transform` declaration — this checker cannot tell " + "which marks it applies to, and a transform on verified geometry " + "invalidates every coordinate here. Remove it, or bake the offset " + "into the coordinates" + % (name, line_of(source, match.start("body") + found.start())) + ) + + +def check_axes(series: list, findings: list, source: str, name: str) -> None: + """Every series must span the same two axis positions.""" + for attr, end in (("x1", "from"), ("x2", "to")): + positions = sorted({round(s.axis_x(end), 3) for s in series}) + if len(positions) > 1: + offenders = ", ".join("%s at %g" % (s.name, s.axis_x(end)) for s in series) + findings.append( + "%s:%d: series do not share one %s — found %s (%s). Every line must " + "run between the same two axes or the slopes are not comparable" + % (name, line_of(source, series[0].offset), attr, + "/".join("%g" % p for p in positions), offenders) + ) + + +def check_scale(series: list, findings: list, source: str, name: str) -> None: + """The two axes must share one linear value-to-y map, and no point may drift.""" + left = [(s.frm, s.y1) for s in series] + right = [(s.to, s.y2) for s in series] + combined = left + right + values = [v for v, _ in combined] + span = max(values) - min(values) + mid = (max(values) + min(values)) / 2.0 + line = line_of(source, series[0].offset) + + left_fit = fit(left) + right_fit = fit(right) + + if left_fit[0] is not None and right_fit[0] is not None: + slope_drift = abs(left_fit[0] - right_fit[0]) * span + if slope_drift > SCALE_TOLERANCE: + findings.append( + "%s:%d: the two axes are not on the same scale — %.4g px/unit on the " + "left against %.4g on the right, which puts the same value up to " + "%.1f px apart across the plotted range. Both axes must use one scale " + "or every slope is a lie" + % (name, line, left_fit[0], right_fit[0], slope_drift) + ) + return + # Compared at mid-domain, not at value zero. Raw intercepts are the + # predicted y AT zero, so on values in the billions two axes sharing one + # transform disagreed by hundreds of pixels there through nothing but + # float cancellation, and an honest figure was reported as shifted. + origin_drift = abs((left_fit[0] * mid + left_fit[1]) + - (right_fit[0] * mid + right_fit[1])) + if origin_drift > ORIGIN_TOLERANCE: + findings.append( + "%s:%d: the two axes share a scale but not an origin — offset by " + "%.1f px at mid-domain. A shifted axis tilts every slope by the same " + "amount, so the comparison between series survives and every rate is " + "wrong" % (name, line, origin_drift) + ) + return + # Both returns above are deliberate. Per-point residuals are measured + # against "the" shared scale, so once the two axes disagree about what + # that scale is there is nothing meaningful to measure against - every + # point reports an error and the one finding that names the cause is + # buried in its own consequences. + elif left_fit[0] is None and right_fit[0] is None: + findings.append( + "%s:%d: neither axis has two distinct values, so the scale cannot be " + "derived and no slope here is verifiable" % (name, line) + ) + return + + ends = ["from"] * len(left) + ["to"] * len(right) + for index, drawn, expected in outliers(combined, RESIDUAL_TOLERANCE): + s = series[index % len(series)] + value = combined[index][0] + findings.append( + "%s:%d: series %r draws its %s endpoint (%g) at y=%g where the shared " + "scale its peers describe puts %g at y=%.1f — off by %.1f px. Do not move " + "a point to make room for its label" + % (name, line_of(source, s.offset), s.name, ends[index], value, drawn, + value, expected, abs(drawn - expected)) + ) + + +def misplaced_row(series: list, owner: str, end: str, y: float): + """A series whose endpoint is strictly nearer this label than its own owner's. + + Nearer, not nearest. Two series holding the same value at an end sit at the + same y by necessity - that is the honest rendering of equal values, and it is + also what an indexed slopegraph does at every from-endpoint - so a tie must + pass. Only a label that has drifted onto a row genuinely closer to someone + else has been mislabelled. + """ + own = min(abs(y - s.endpoint_y(end)) for s in series if s.name == owner) + for other in series: + if other.name != owner and abs(y - other.endpoint_y(end)) < own - ROW_TOLERANCE: + return other.name + return None + + +def collect_bound_labels(source: str, findings: list, name: str) -> dict: + """Bound labels keyed by (series, end, role), with duplicates reported.""" + found = {} + for match in TEXT_RE.finditer(source): + attrs = attrs_of(match.group("attrs")) + label, end = attrs.get("data-series"), attrs.get("data-end") + if label is None or end is None: + continue + role = attrs.get("data-role", "value") + key = (label, end, role) + if key in found: + # A second label for one endpoint used to overwrite the first, so a + # figure could print two contradictory numbers for one point and be + # judged on whichever came last. + findings.append( + "%s:%d: a second %s %s label for series %r — one endpoint, one " + "label, or the figure states two things about one point" + % (name, line_of(source, match.start()), end, role, label) + ) + continue + found[key] = (plain(match.group("body")), number(attrs.get("y")), + match.start()) + return found + + +def check_labels(series: list, source: str, findings: list, name: str) -> None: + """Printed values and names must exist, be unique, match, and sit on their row.""" + bound = collect_bound_labels(source, findings, name) + declared = {s.name for s in series} + + for (label, end, role), (body, y, offset) in sorted( + bound.items(), key=lambda item: item[1][2] + ): + if label not in declared: + findings.append( + "%s:%d: a %s label names series %r, which no line declares — a label " + "with no mark is not verifiable and reads as data" + % (name, line_of(source, offset), role, label) + ) + continue + if end not in ("from", "to"): + findings.append( + "%s:%d: label for series %r has data-end=%r, which is neither " + "'from' nor 'to'" % (name, line_of(source, offset), label, end) + ) + continue + # Row placement. A label bound to one series but drawn beside another's + # endpoint mislabels the row, and no value check would notice: two names + # exchanged between rows leaves every number correct in isolation. + if y is not None: + sits_by = misplaced_row(series, label, end, y) + if sits_by is not None: + findings.append( + "%s:%d: the %s %s label for series %r is drawn at y=%g, nearer " + "%r's endpoint than its own — a label on the wrong row renames " + "the line" + % (name, line_of(source, offset), end, role, label, y, sits_by) + ) + + for s in series: + for end in ("from", "to"): + value_entry = bound.get((s.name, end, "value")) + if value_entry is None: + findings.append( + "%s:%d: series %r prints no %s endpoint value — a slopegraph must " + "label both ends, or the reader has a slope and no magnitude" + % (name, line_of(source, s.offset), s.name, end) + ) + else: + body, _y, offset = value_entry + shown, reason = printed_number(body) + if shown is None: + findings.append( + "%s:%d: the %s endpoint label for %r %s (%r) — label both " + "endpoints with one complete value each" + % (name, line_of(source, offset), end, s.name, reason, + body[:28]) + ) + elif abs(shown - s.value(end)) > VALUE_TOLERANCE: + findings.append( + "%s:%d: series %r prints %r at its %s endpoint but declares " + "%g — the label and the geometry must state one number" + % (name, line_of(source, offset), s.name, body, end, + s.value(end)) + ) + name_entry = bound.get((s.name, end, "name")) + if name_entry is None: + findings.append( + "%s:%d: series %r has no %s name label (data-role=\"name\") — a " + "slopegraph names every series at both ends, and an unbound name " + "can be swapped with another row undetected" + % (name, line_of(source, s.offset), s.name, end) + ) + continue + body, _y, offset = name_entry + if body != s.name: + findings.append( + "%s:%d: a name label bound to series %r reads %r — the visible " + "name and the binding must agree" + % (name, line_of(source, offset), s.name, body[:28]) + ) + + +def check_captions(series: list, source: str, findings: list, name: str) -> None: + """Each state caption must name the axis it is drawn against.""" + seen = {} + for match in TEXT_RE.finditer(source): + attrs = attrs_of(match.group("attrs")) + end = attrs.get("data-axis") + if end is None: + continue + if end not in ("from", "to"): + findings.append( + "%s:%d: a state caption has data-axis=%r, which is neither 'from' " + "nor 'to'" % (name, line_of(source, match.start()), end) + ) + continue + if end in seen: + findings.append( + "%s:%d: a second %r state caption — one axis, one caption" + % (name, line_of(source, match.start()), end) + ) + continue + seen[end] = (number(attrs.get("x")), plain(match.group("body")), + attrs.get("data-state"), match.start()) + + for end in ("from", "to"): + if end not in seen: + findings.append( + "%s: no %r state caption (data-axis=%r) — an unbound caption pair can " + "be swapped, which reverses the reading of the whole figure" + % (name, end, end) + ) + continue + x, body, declared, offset = seen[end] + expected = series[0].axis_x(end) + if x is None or abs(x - expected) > CAPTION_TOLERANCE: + findings.append( + "%s:%d: the %r state caption (%r) is drawn at x=%s but its axis is at " + "x=%g — the captions are swapped or misplaced, which reverses the " + "direction every slope is read in" + % (name, line_of(source, offset), end, body[:20], + "%g" % x if x is not None else "?", expected) + ) + # Position alone is not enough: swapping just the two visible strings + # leaves both captions where they were and reverses the figure anyway. + # data-state binds the text, exactly as data-series binds a series name. + if declared is None: + findings.append( + "%s:%d: the %r state caption (%r) has no data-state — its visible " + "text is unbound, so it can be exchanged with the other caption and " + "nothing here would notice" + % (name, line_of(source, offset), end, body[:20]) + ) + elif body != declared: + findings.append( + "%s:%d: the %r state caption reads %r but declares data-state=%r — the " + "visible caption and its binding must agree" + % (name, line_of(source, offset), end, body[:20], declared) + ) + + if len(seen) == 2: + first, second = seen["from"], seen["to"] + if first[1] and first[1] == second[1]: + findings.append( + "%s:%d: both state captions read %r — two states the reader cannot " + "tell apart is not a comparison" + % (name, line_of(source, second[3]), first[1]) + ) + + +def check_source(path: Path, raw: str) -> list: + """Findings for one already-read document.""" + source = blank_comments(raw) + findings: list = [] + series = parse_series(source, findings, path.name) + + if len(series) < 2: + findings.append( + "%s: presents as a slopegraph but declares %d verifiable series — every " + "line needs data-series with data-from and data-to. Refusing to report " + "OK on a file this checker could not read" % (path.name, len(series)) + ) + return findings + + check_transforms(source, findings, path.name) + check_axes(series, findings, source, path.name) + check_scale(series, findings, source, path.name) + check_labels(series, source, findings, path.name) + check_captions(series, source, findings, path.name) + return findings + + +def check(path: Path) -> list: + """Findings for one file on disk, or [] if it is not a slopegraph.""" + raw = path.read_text(encoding="utf-8") + if not looks_like_slopegraph(path, raw): + return [] + return check_source(path, raw) + + +def targets(args: argparse.Namespace) -> list: + if args.all: + return sorted(ASSET_DIR.glob("example-*.html")) + return [Path(p) for p in args.paths] + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Verify slopegraph slopes against the values they are labelled with." + ) + parser.add_argument("paths", nargs="*", help="HTML files to check") + parser.add_argument( + "--all", action="store_true", + help="check every shipped example that presents as a slopegraph", + ) + args = parser.parse_args() + if not args.all and not args.paths: + parser.print_help() + return 2 + + findings: list = [] + checked = 0 + skipped = 0 + for path in targets(args): + if not path.is_file(): + print("error: %s is not a readable file" % path, file=sys.stderr) + return 2 + try: + raw = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as error: + print("error: cannot read %s: %s" % (path, error), file=sys.stderr) + return 2 + # Read once, then decide. Scope is reported separately from verification + # in both modes: printing "1 file(s) verified" for a bar chart that was + # skipped is a claim the run never made good on. + if not looks_like_slopegraph(path, raw): + skipped += 1 + continue + findings.extend(check_source(path, raw)) + checked += 1 + + for finding in findings: + print(finding) + tail = " (%d file(s) skipped as out of scope)" % skipped if skipped else "" + if findings: + print("\n%d slopegraph finding(s) across %d file(s).%s" + % (len(findings), checked, tail)) + return 1 + if not checked: + print("OK slopegraph: no slopegraph found to check%s" % tail) + return 0 + print("OK slopegraph: %d file(s), one shared scale on both axes, no transforms on " + "verified geometry, and every printed value, name and caption bound to what " + "it describes%s" % (checked, tail)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/diagram-design/SKILL.md b/skills/diagram-design/SKILL.md index 2c069f0..8c50579 100644 --- a/skills/diagram-design/SKILL.md +++ b/skills/diagram-design/SKILL.md @@ -101,7 +101,7 @@ The pattern owns semantic primitives and its tighter budget; the type owns layou | Ranked hierarchy or conversion drop-off | **Pyramid / funnel** | [type-pyramid.md](references/type-pyramid.md) | | Quantitative comparison across categories | **Bar chart** | [type-bar.md](references/type-bar.md) | | Part-of-whole where the relative sizes are the story | **Treemap** | [type-treemap.md](references/type-treemap.md) | -| Continuous trends over time | **Line chart** | [type-line.md](references/type-line.md) | +| Continuous trends over time, or change between exactly two states (slopegraph) | **Line chart** | [type-line.md](references/type-line.md) | | Tasks and phases on a timeline | **Gantt** | [type-gantt.md](references/type-gantt.md) | | Distribution and correlation between two variables | **Scatter plot** | [type-scatter.md](references/type-scatter.md) | | End-to-end data stack on a container cluster | **High-Level** | [type-high-level.md](references/type-high-level.md) | diff --git a/skills/diagram-design/assets/example-slopegraph-dark.html b/skills/diagram-design/assets/example-slopegraph-dark.html new file mode 100644 index 0000000..fb955f6 --- /dev/null +++ b/skills/diagram-design/assets/example-slopegraph-dark.html @@ -0,0 +1,119 @@ + + + + + + p95 by service, before and after · Slopegraph · dark + + + + +
+

Slopegraph · Diagram Design

+

One service went the other way

+ + + One service went the other way + Slopegraph of p95 response time by service before and after a caching layer, on one shared 100-to-550-millisecond scale; four services got faster and the recommender got slower, crossing three of them. + + + + + + + + + + + P95 RESPONSE TIME, MS + + + + BEFORE + + AFTER + + + + + Search + 512 + 208 + Search + + + + + Catalog + 376 + 164 + Catalog + + + + + Checkout + 291 + 143 + Checkout + + + + + Auth + 154 + 121 + Auth + + + + + + Recommender + 238 + 431 + Recommender + + + + LEGEND + P95 RESPONSE TIME IN MS, LOWER IS BETTER · SAME 100-550 SCALE ON BOTH AXES · ILLUSTRATIVE FIGURES, NOT A MEASURED RELEASE + + + + Recommender · focal, the only regression + + + Other services · strongest tone was slowest before + +
+ + diff --git a/skills/diagram-design/assets/example-slopegraph-full.html b/skills/diagram-design/assets/example-slopegraph-full.html new file mode 100644 index 0000000..b7cf685 --- /dev/null +++ b/skills/diagram-design/assets/example-slopegraph-full.html @@ -0,0 +1,149 @@ + + + + + + p95 by service, before and after · Slopegraph + + + + +
+
+

Slopegraph · Diagram Design

+

One service went the other way

+

p95 response time per service before and after the caching layer, both axes on one 100-to-550ms scale. Four services got faster. The recommender got 81% slower, and being the only line going up it crosses three of them.

+
+
+ + One service went the other way + Slopegraph of p95 response time by service before and after a caching layer, on one shared 100-to-550-millisecond scale; four services got faster and the recommender got slower, crossing three of them. + + + + + + + + + + + P95 RESPONSE TIME, MS + + + + BEFORE + + AFTER + + + + + Search + 512 + 208 + Search + + + + + Catalog + 376 + 164 + Catalog + + + + + Checkout + 291 + 143 + Checkout + + + + + Auth + 154 + 121 + Auth + + + + + + Recommender + 238 + 431 + Recommender + + + + LEGEND + P95 RESPONSE TIME IN MS, LOWER IS BETTER · SAME 100-550 SCALE ON BOTH AXES · ILLUSTRATIVE FIGURES, NOT A MEASURED RELEASE + + + + Recommender · focal, the only regression + + + Other services · strongest tone was slowest before + +
+
+
+

FOCAL · RECOMMENDER

+

The recommender went from fourth-slowest to slowest

+

238ms to 431ms. It is the only line that rises, so it crosses three of the other four — all but Auth, which stays below it throughout. Where it crosses on the page is an artifact of joining two endpoints with a straight line: this chart cannot say when the regression started, only that it is there.

+
+
+

Search won the most and is still the second slowest

+

512ms to 208ms, down 304 — the steepest slope here. It started so far above everything else that the largest absolute win still leaves it near the top. Level and change share one scale, which is what lets you read both at once.

+
+
+

Auth barely moved because it had nothing to win

+

154ms to 121ms, down 33. Nearly flat, and low on the axis — a shallow slope down here means a service that was already fast, while the same shallow slope near the top would mean a cache that did not work.

+
+
+ +
+ + diff --git a/skills/diagram-design/assets/example-slopegraph.html b/skills/diagram-design/assets/example-slopegraph.html new file mode 100644 index 0000000..ecd5fc8 --- /dev/null +++ b/skills/diagram-design/assets/example-slopegraph.html @@ -0,0 +1,119 @@ + + + + + + p95 by service, before and after · Slopegraph + + + + +
+

Slopegraph · Diagram Design

+

One service went the other way

+ + + One service went the other way + Slopegraph of p95 response time by service before and after a caching layer, on one shared 100-to-550-millisecond scale; four services got faster and the recommender got slower, crossing three of them. + + + + + + + + + + + P95 RESPONSE TIME, MS + + + + BEFORE + + AFTER + + + + + Search + 512 + 208 + Search + + + + + Catalog + 376 + 164 + Catalog + + + + + Checkout + 291 + 143 + Checkout + + + + + Auth + 154 + 121 + Auth + + + + + + Recommender + 238 + 431 + Recommender + + + + LEGEND + P95 RESPONSE TIME IN MS, LOWER IS BETTER · SAME 100-550 SCALE ON BOTH AXES · ILLUSTRATIVE FIGURES, NOT A MEASURED RELEASE + + + + Recommender · focal, the only regression + + + Other services · strongest tone was slowest before + +
+ + diff --git a/skills/diagram-design/assets/index.html b/skills/diagram-design/assets/index.html index 71928c6..f1ec0b0 100644 --- a/skills/diagram-design/assets/index.html +++ b/skills/diagram-design/assets/index.html @@ -279,6 +279,9 @@ + diff --git a/skills/diagram-design/references/type-line.md b/skills/diagram-design/references/type-line.md index 94c3b76..a80dcdb 100644 --- a/skills/diagram-design/references/type-line.md +++ b/skills/diagram-design/references/type-line.md @@ -37,8 +37,112 @@ - Y-axis that doesn't include zero when the absolute magnitude matters. - Connecting discontinuous data segments without a visual gap. +## Variants + +- **Slopegraph:** exactly two states, several series, read as slope and rank change. Full spec below. + +### Slopegraph + +**Best for:** change between exactly **two** comparable states across several series — two years, before and after, two cohorts, two scenarios. The reading is threefold and no other type gives all three at once: direction (up or down), steepness (how fast), and **crossings** (who overtook whom). Tufte's original (*The Visual Display of Quantitative Information*, 1983) is a table whose rows have been given an angle — every number is still printed, which is why both endpoints keep their labels here. + +Not for: three or more states (that is the **line chart** above, or a bump chart); a single series (write the sentence instead); ranking at one moment with no change (use a **bar chart**); or two variables measured in different units (use a **scatter plot** — a slope between unlike units means nothing). + +#### Layout conventions + +- **Two vertical axis rules**, `y` 40 → 420 inside a `0 0 1000 500` viewBox, at `x` 320 and `x` 680. The plot sits inside `x` 40 → 956 with the rotated value-axis caption at `x=24`, as in the parent chart, and the legend keeps the house rhythm (rule at `y=462`, `LEGEND` at `478`, key swatches at `492` with their text at `496`), so a reader of bar, line or treemap finds it where they expect. +- **Keep the run narrower than the plot is tall.** The 360px run against a 380px height puts the steepest shipped slope at 35° and the flattest at 4° — the spread you need before "halved" and "barely moved" look like different claims. Widening the run flattens every slope toward horizontal and throws away the comparison the type exists to make; the leftover width belongs to the label gutters, which need it. +- **Label gutters:** on the left, names right-aligned ending at `x=272` and values right-aligned ending at `x=304`; mirrored on the right from `x=696` (values) and `x=728` (names). Size the gutter to the longest name — a name that collides with the axis is the one thing here you cannot fix by moving a coordinate. +- **State captions** in Geist Mono 9px, centred under each axis at `y=440`, tracked `0.14em`. +- **Series count:** 4–10 — deliberately more than the parent chart's cap of 5. That cap exists because eight-vertex polylines tangle in mid-plot; a slopegraph has no mid-plot, so what sets the ceiling here is endpoint labels colliding, not the lines. Below 4 a sentence or a pair of bars says it better. +- **No gridlines.** Every endpoint prints its own value, so a gridline carries nothing the figure has not already said; the two axis rules *are* the scale. This is a real departure from the parent line chart, where the gridlines do the reading. +- **Domain:** pick round bounds that contain the data and state them in the source line. The shipped example runs 100–550ms over `y` 420 → 40, i.e. 0.84px per millisecond. The parent chart's include-zero rule does not bind here: a slope is unchanged by moving the origin, provided *both* axes move together. What a tight domain does do is magnify every slope equally, so state the bounds and let the reader calibrate. +- **Legend keys are a 24px line plus its dot**, not the 16×8 rect the parent section specifies — which is also what `example-line.html` actually ships, the parent prose being stale on this point. A slopegraph key has to show stroke weight, because weight is what marks the focal series. +- **Dots at both endpoints** — `r=3` non-focal, `r=4` focal. Also a departure: the line chart puts dots on the focal series only, because it has eight vertices per series and dots everywhere would be mush. A slopegraph has exactly two per series and both are where a value is read. +- **4px grid** applies to the designed constants — axis positions, gutter edges, state-caption baselines. Endpoint `y` values are data-scaled and exempt; snapping them would move the data. Two inherited constants are also off-grid and stay that way: the legend rule at `y=462` and the `LEGEND`/source baseline at `478`, which every chart type in `assets/` shares. Matching the house rhythm beats matching the grid here — moving them 4px would misalign this variant against bar, line, scatter and treemap to satisfy a rule none of them satisfies either. + +#### Colour + +- **One accent, and an `ink` opacity ramp for everything else** — not the series palette. In a line chart hue is the only way to follow a series across eight x-positions, so `series-1`…`series-4` earn their place. Here every series is named at both ends, so hue would be a second encoding of something the labels already carry, and it would spend the one-accent rule for nothing. +- **The ramp runs 0.80 → 0.62**, ordered by the left-hand value. The hard floor is **0.53** — that is where an `ink` stroke crosses 3:1 against light paper (0.53 measures 3.03:1, 0.52 measures 2.95:1), and every line in this figure is data, not decoration. The shipped ramp bottoms out at 0.62 (3.84:1) rather than hugging the floor, because a ramp whose lightest member is only just legible has no room left to add a series. +- **Note what the ramp does not buy you.** It separates the ends of the range, not adjacent members — 0.80 and 0.74 are not distinguishable at 1.2px, as the shipped example shows. It is there to help trace one line through a crossing. It must never be the only way to tell two series apart; that is the labels' job. +- **The accent marks the editorially focal series, not the best or the biggest.** In the shipped example it marks the one service that got *worse*. +- **Focus is carried by stroke weight, not tone** — 2.4px focal against 1.2px. Check the token you actually ship: `accent` measures **2.86:1 on light paper** and 5.21:1 on dark, so on light paper the focal line has *less* contrast than the ink ramp it is meant to dominate, and weight is the only cue that survives both skins and greyscale. +- **Be honest about what that leaves.** 2.86:1 is below WCAG 1.4.11's 3:1 floor for a graphical object, and a heavier stroke does not raise a contrast ratio — it only makes the mark easier to find. The focal line clears the bar on redundancy rather than on contrast: its position and its two endpoint labels (`ink` at 11.8:1, `muted` at 6.1:1) carry the data, and its accent adds only *which series is focal*, which the legend states in words and the stroke weight repeats. Nothing here rests on the accent alone. This is a property of the skin's accent token on light paper, not of this variant — the focal bar, the focal line and the focal treemap cell inherit it too, so fixing it properly means changing `accent` in style-guide.md. +- **Labels stay `ink` (names) and `muted` (values) on every series, including the focal one.** Accent text at 9–11px misses AA on light paper at that same 2.86:1. A focal value label in accent is the most common way to make a slopegraph fail contrast while looking deliberate. +- **Legend wording must be skin-neutral: "strongest tone", never "darkest".** The ramp is ink-at-opacity, so the top of it is the darkest line on light paper and the *lightest* on dark. A legend that says "darker is higher" ships false in one of the two variants — and it renders perfectly in both, so only reading the dark file catches it. + +#### Honest-data rule + +**Both axes must carry the same scale and the same units.** That is the entire claim of the type: if the scales differ, the angle of every line is fiction. `scripts/verify-slopegraph.py` gates it. + +- **The two axes must never differ from each other** — not in scale, not in origin, not in transform. A shared *origin* matters as much as a shared scale: shifting one axis tilts every slope by the same amount, so the series still rank correctly against each other while every rate is wrong. That is the harder version to spot by eye, which is why the checker tests slope and origin separately. +- **A domain tighter than zero is fine; an undisclosed one is not.** Both axes sharing a 100–550 window is legitimate, because moving the origin leaves every slope unchanged when both axes move together — this is exactly where a slopegraph differs from a bar chart, whose truncated baseline distorts the ratio between bars. What a tight window does do is magnify all slopes equally, so the bounds go in the source line and the reader calibrates. Log-scaling is a different matter and stays out: it makes the angle mean nothing. +- **Label both endpoints with actual values.** A slope with no magnitudes is a mood. +- **Round once, then draw from the rounded number**, so the printed label, the declared metadata and the drawn `y` are three statements of one number rather than three chances to disagree. +- **If a series is missing an endpoint, drop it and say so.** Never interpolate to complete a line. +- **Crowded endpoint labels are data.** When two series sit a few tenths apart their labels crowd *because the values are close*. Moving a point to open up space converts a legibility problem into a false statement — and it is invisible afterwards, because the label you moved it for now sits comfortably beside the wrong position. This is the specific defect the gate exists for. +- **Two series that coincide at both endpoints cannot be separated at all.** Merge them into one labelled line, or drop one and name the omission in the source line. What you must not do is nudge them apart, which is the same defect as above wearing a better excuse. +- **The straight line is a connector, not a trajectory.** Two endpoints say nothing about the path between them: the underlying series may have dipped, spiked, or crossed three times. So never read an intermediate value off a slope, and never annotate a crossing with a date. In the shipped example one service regresses while four improve, so its line crosses three others; none of the three crossings is annotated, and the card beside the figure says why. + +#### Declaring the values + +**Every visible string that carries meaning is bound to an attribute stating the same thing.** That is the whole contract, and it exists because each unbound string is a place the figure can be made to lie while every geometric check stays green. + +```svg + +BEFORE +AFTER + + + + + +Recommender +238 +431 +Recommender +``` + +Non-focal series: `stroke="rgba(45,49,66,0.68)"` at `stroke-width="1.2"`, dots `r=3`, names at `font-weight="500"`. + +What each binding buys, and what it costs to omit: + +| Binding | Without it | +|---|---| +| `data-from` / `data-to` on the line | The checker would have to read the labels, and a series whose label went missing would drop silently out of the verified set — the exact hole that let a treemap cell ship 50% oversized. | +| `data-end` on a value label | A printed number could not be cross-checked against the value it claims to state. | +| `data-role="name"` on a name label | Two series names could be exchanged between rows, renaming both lines, with every number still correct in isolation. | +| `data-axis` on a state caption | The captions could be swapped, reversing the direction every slope is read in. | +| `data-state` on a state caption | Swapping just the two visible strings leaves both captions in place and reverses the figure anyway. | + +`scripts/verify-slopegraph.py` requires all of them, cross-checks each visible string against its binding, and reports any label drawn nearer another series' endpoint than its own — a label on the wrong row renames the line. + +**No `transform` on any of it.** The checker reads raw `x`/`y` attributes, so a `transform` on a series line, on a bound label, on an ancestor ``, or in a CSS rule moves the rendered mark away from the number that was verified — `transform="translate(0 80)"` on one line slid its endpoint 80px past every green check. Transforms are rejected rather than resolved: a partial implementation of the SVG transform stack looks like coverage without being it. Bake the offset into the coordinates. The rotated value-axis caption is fine — it is neither verified geometry nor a bound label. + +**Print one complete number per value label.** A unit suffix is fine (`208ms`); a thousands separator that changes the value is not, and neither is a second number in the same label. Reading only the first numeric fragment let the label `512,000` agree with metadata that said `512`. + +#### Anti-patterns + +- Different scales, different units, or different origins on the two axes — the type's one unforgivable error. +- A value axis whose two halves disagree, or a tight domain the source line never states. +- Moving an endpoint to make room for its label. +- More than 10 series (labels collide) or fewer than 4 (a sentence is shorter). +- Three or more state columns — that is the parent line chart, or a bump chart, not this. +- One hue per series instead of the ink ramp plus a single accent. +- A value label in `accent`, or any text in `soft` (3.48:1 on paper). +- Gridlines, or a value axis that repeats numbers the endpoints already print. +- Annotating the crossing with a date, or reading any intermediate value off a slope. +- A `transform` on a series line, a bound label, an ancestor group, or in CSS — it moves the mark away from the checked coordinate. +- An unbound visible string: a name, a value or a state caption with no attribute stating the same thing. +- Curving the connector. There is nothing between the two points to curve through. + ## Examples - `assets/example-line.html` — minimal light - `assets/example-line-dark.html` — minimal dark - `assets/example-line-full.html` — full editorial +- `assets/example-slopegraph.html` — slopegraph, minimal light +- `assets/example-slopegraph-dark.html` — slopegraph, minimal dark +- `assets/example-slopegraph-full.html` — slopegraph, full editorial