diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 6620700..c2a4ee7 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, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
- "version": "2.6.4",
+ "version": "2.6.5",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
index 1510523..2ac613f 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, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
- "version": "2.6.4",
+ "version": "2.6.5",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
diff --git a/.factory-plugin/plugin.json b/.factory-plugin/plugin.json
index 6620700..c2a4ee7 100644
--- a/.factory-plugin/plugin.json
+++ b/.factory-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
- "version": "2.6.4",
+ "version": "2.6.5",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 37866e8..74b4c3d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -259,6 +259,16 @@ jobs:
id: ridgeline_tests
run: python scripts/test-verify-ridgeline.py
+ - name: Verify bubble positions and area encoding
+ if: always()
+ id: bubble
+ run: python scripts/verify-bubble.py --all
+
+ - name: Verify bubble checker
+ if: always()
+ id: bubble_tests
+ run: python scripts/test-verify-bubble.py
+
# lint-render.py's oracle is pixels, so the browser is part of the contract:
# pin Playwright exactly, and let that pin choose the Chromium build. Bump
# both together, deliberately, by editing PLAYWRIGHT_VERSION.
@@ -337,6 +347,8 @@ jobs:
echo "| Sankey Checker | ${{ steps.sankey_tests.outcome == 'success' && '✅ Passed' || (steps.sankey_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Ridgeline Shared Amplitude | ${{ steps.ridgeline.outcome == 'success' && '✅ Passed' || (steps.ridgeline.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Ridgeline Checker | ${{ steps.ridgeline_tests.outcome == 'success' && '✅ Passed' || (steps.ridgeline_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Bubble Position & Area | ${{ steps.bubble.outcome == 'success' && '✅ Passed' || (steps.bubble.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Bubble Checker | ${{ steps.bubble_tests.outcome == 'success' && '✅ Passed' || (steps.bubble_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Primitive Icons & Documentation | ${{ steps.icons.outcome == 'success' && '✅ Passed' || (steps.icons.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Render Linter Self-Test | ${{ steps.render_selftest.outcome == 'success' && '✅ Passed' || (steps.render_selftest.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Rendered Layout (paint oracle) | ${{ steps.render.outcome == 'success' && '✅ Passed' || (steps.render.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
diff --git a/.maintainer-policy.json b/.maintainer-policy.json
index fd23594..6f124a5 100644
--- a/.maintainer-policy.json
+++ b/.maintainer-policy.json
@@ -53,6 +53,8 @@
"python3 scripts/test-verify-slopegraph.py",
"python3 scripts/verify-ridgeline.py --all",
"python3 scripts/test-verify-ridgeline.py",
+ "python3 scripts/verify-bubble.py --all",
+ "python3 scripts/test-verify-bubble.py",
"python3 scripts/build-icons.py && git diff --ignore-space-at-eol --exit-code -- skills/diagram-design/assets/icons.html skills/diagram-design/references/primitive-icons.md"
],
"required_check_names": [],
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6792cdd..43ede7b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -71,6 +71,8 @@ The helper refuses to run if the Claude and Codex versions already differ. If an
| Ridgeline checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-ridgeline.py` |
| Sankey flow conservation and ribbon geometry | `python3 scripts/verify-sankey.py --all` |
| Sankey checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-sankey.py` |
+| Bubble positions sit on shared axis scales and area encodes every declared size | `python3 scripts/verify-bubble.py --all` |
+| Bubble checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-bubble.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.
@@ -112,7 +114,9 @@ python3 scripts/test-plugin-package.py \
&& python3 scripts/verify-ridgeline.py --all \
&& python3 scripts/test-verify-ridgeline.py \
&& python3 scripts/verify-sankey.py --all \
- && python3 scripts/test-verify-sankey.py
+ && python3 scripts/test-verify-sankey.py \
+ && python3 scripts/verify-bubble.py --all \
+ && python3 scripts/test-verify-bubble.py
```
### If a gate fails
@@ -123,6 +127,7 @@ python3 scripts/test-plugin-package.py \
- **`verify-screenshot-freshness.py`:** a canonical minimal-light example or its committed PNG changed without a synchronized catalog refresh. Before the first regeneration, install the renderer with `python3 -m pip install playwright && python3 -m playwright install chromium`. Then run `python3 scripts/render-canonical-screenshots.py`, inspect all 39 renders, and commit the updated PNGs plus `docs/screenshots/manifest.json`.
- **`verify-slopegraph.py`:** the two axes disagree about scale or origin, or an endpoint is drawn somewhere other than where its own declared value belongs. Fix the coordinate, never the label — and never move a point to stop two endpoint labels colliding, because crowded labels mean the values really are close.
- **`verify-ridgeline.py`:** a ridge is drawn on its own amplitude, a baseline sits off the stack's pitch or away from its drawn rule, a ridge is sampled on its own x positions, or a printed range is wider than the bins it claims. Fix the geometry or the declaration so they state one thing — never renormalise a single ridge to make it readable, and never move a baseline to buy one ridge headroom.
+- **`verify-bubble.py`:** a bubble is drawn off the shared axis scale its peers describe, its radius disagrees with the one area constant (`r = K·√size`), a second bubble wears the accent, an overlapping smaller bubble is painted under a larger one, or a bound label/tick disagrees with the mark it names. Fix the geometry, never the binding — and never nudge a bubble to open up space, because crowded bubbles 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-bubble.py b/scripts/test-verify-bubble.py
new file mode 100644
index 0000000..068f08e
--- /dev/null
+++ b/scripts/test-verify-bubble.py
@@ -0,0 +1,572 @@
+#!/usr/bin/env python3
+"""Adversarial cases for verify-bubble.py, both polarities.
+
+Every case is named for exactly what it asserts — a name that overclaims is
+itself a defect. The negative half matters as much as the positive: a checker
+that fires on an honest bubble chart, on the parent scatter, or on the shipped
+slopegraph gets widened or switched off, and then it guards nothing.
+
+Fixtures live in a per-process temporary directory, never under the repository
+root, and two cases at the end hold that isolation in place.
+
+Exit: 0 all pass, 1 any failure.
+"""
+
+from __future__ import annotations
+
+import math
+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-bubble")
+
+ASSETS = ROOT / "skills/diagram-design/assets"
+SHIPPED = [ASSETS / name for name in (
+ "example-bubble.html", "example-bubble-dark.html", "example-bubble-full.html",
+)]
+
+HEAD = """
+
t
+\n"
+
+# The fixture scale, matching the shipped example: x = 80 + 1.76*v over a
+# 0-500 domain, y = 420 - 95*v over 0-4, and r = 1.4*sqrt(size).
+def cx(value: float) -> float:
+ return round(80 + 1.76 * value, 1)
+
+
+def cy(value: float) -> float:
+ return round(420 - 95 * value, 1)
+
+
+def radius(size: float) -> float:
+ return round(1.4 * math.sqrt(size), 1)
+
+
+def bubble(name: str, x: float, y: float, size: float, drawn_cx=None,
+ drawn_cy=None, drawn_r=None, stroke: str = "#4f5d75",
+ omit: str = "", extra: str = "") -> str:
+ parts = []
+ if omit != "data-name":
+ parts.append('data-name="%s"' % name)
+ parts.append('data-x="%s"' % x)
+ parts.append('data-y="%s"' % y)
+ parts.append('data-size="%s"' % size)
+ if omit != "cx":
+ parts.append('cx="%s"' % ("%g" % cx(x) if drawn_cx is None else drawn_cx))
+ parts.append('cy="%s"' % ("%g" % cy(y) if drawn_cy is None else drawn_cy))
+ parts.append('r="%s"' % ("%g" % radius(size) if drawn_r is None else drawn_r))
+ if extra:
+ parts.append(extra)
+ return (' \n' % (" ".join(parts), stroke))
+
+
+def label(name: str, x: float, y: float, text=None, extra: str = "") -> str:
+ return (' %s\n'
+ % (name, x, y, (" " + extra) if extra else "",
+ name.upper() if text is None else text))
+
+
+def tick(axis: str, value: float, position=None, shown=None,
+ declared=None, extra: str = "") -> str:
+ if position is None:
+ position = cx(value) if axis == "x" else cy(value) + 4
+ coords = ('x="%g" y="440"' % position) if axis == "x" else \
+ ('x="72" y="%g"' % position)
+ bind = "" if declared == "omit" else \
+ ' data-value="%s"' % (value if declared is None else declared)
+ return (' %s\n'
+ % (axis, bind, coords, (" " + extra) if extra else "",
+ ("%g" % value) if shown is None else shown))
+
+
+TICKS = tick("x", 0) + tick("x", 500) + tick("y", 0) + tick("y", 4)
+
+# Five honest bubbles with distinct values on every axis; none overlap.
+BUBBLES = [("Edge", 100, 1.0, 400), ("Queue", 200, 2.0, 100),
+ ("Batch", 300, 0.5, 225), ("Webhook", 400, 3.0, 25),
+ ("Stream", 150, 1.5, 900)]
+
+
+def honest_block(rows=None, focal: str = "Edge") -> str:
+ body = ""
+ for name, x, y, size in sorted(BUBBLES if rows is None else rows,
+ key=lambda row: -row[3]):
+ stroke = "#eb6c36" if name == focal else "#4f5d75"
+ body += bubble(name, x, y, size, stroke=stroke)
+ return body
+
+
+def document(*blocks: str) -> str:
+ return HEAD + "".join(blocks) + TAIL
+
+
+def honest(rows=None, focal: str = "Edge") -> str:
+ return document(honest_block(rows, focal), TICKS)
+
+
+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="bubble-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-bubble-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, case: 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" % case)
+ for item in found:
+ print(" unexpected: %s" % item)
+ else:
+ print("ok %s" % case)
+
+ def expect_finding(self, case: 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" % case)
+ return
+ if not any(re.search(pattern, item) for item in found):
+ self.failures += 1
+ print("FAIL %s\n no finding matched %r" % (case, pattern))
+ for item in found:
+ print(" got: %s" % item)
+ return
+ print("ok %s" % case)
+
+ def expect_only_one(self, case: 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" % case)
+ return
+ self.failures += 1
+ print("FAIL %s\n expected exactly one finding matching %r, got %d"
+ % (case, pattern, len(found)))
+ for item in found:
+ print(" got: %s" % item)
+
+ def expect_out_of_scope(self, case: str, source: str, name: str) -> None:
+ """Not merely finding-free — genuinely outside this checker's scope.
+
+ expect_clean cannot tell the two apart: a detected-and-clean file also
+ produces no findings, which is not what "out of scope" claims.
+ """
+ self.count += 1
+ path = self.path_for(name)
+ path.write_text(source, encoding="utf-8")
+ try:
+ detected = verify.looks_like_bubble(path, source)
+ found = verify.check(path)
+ finally:
+ path.unlink()
+ if detected or found:
+ self.failures += 1
+ print("FAIL %s\n detected=%s findings=%d"
+ % (case, detected, len(found)))
+ return
+ print("ok %s" % case)
+
+ def check(self, case: str, condition: bool, detail: str = "") -> None:
+ self.count += 1
+ if condition:
+ print("ok %s" % case)
+ return
+ self.failures += 1
+ print("FAIL %s%s" % (case, ("\n " + detail) if detail else ""))
+
+
+def run_cases(h: Harness) -> int:
+ # ── Positive polarity: honest figures pass ────────────────────────────
+ for path in SHIPPED:
+ found = verify.check(path)
+ h.check("shipped %s verifies clean" % path.name, found == [],
+ "; ".join(found))
+
+ h.expect_clean("an honest synthetic bubble chart passes", honest())
+ h.expect_clean("a chart with no accent bubble at all passes",
+ honest(focal="nobody"))
+ h.expect_clean(
+ "single-quoted attributes are parsed, not dropped",
+ document(honest_block().replace('"', "'"), TICKS),
+ )
+ h.expect_clean(
+ "a lying bubble inside a comment is markup that never renders",
+ document(honest_block(), TICKS,
+ " \n" % bubble("Ghost", 100, 1.0, 400,
+ drawn_cx=900).strip()),
+ )
+
+ # An inverted y (value grows downward) is a legitimate design; the checker
+ # verifies a SHARED scale, not a direction.
+ inverted = ""
+ for name, x, y, size in sorted(BUBBLES, key=lambda row: -row[3]):
+ inverted += bubble(name, x, y, size, drawn_cy=round(40 + 95 * y, 1))
+ inverted_ticks = (tick("x", 0) + tick("x", 500)
+ + tick("y", 0, position=44) + tick("y", 4, position=424))
+ h.expect_clean("an inverted y axis with a shared scale passes",
+ document(inverted, inverted_ticks))
+
+ # Overlap honesty. Pair that genuinely overlaps: (256, 325, r28) and
+ # (273.6, 315.5, r14) are 20px apart against 42px of radius.
+ big = bubble("Big", 100, 1.0, 400)
+ small = bubble("Small", 110, 1.1, 100)
+ h.expect_clean("overlapping bubbles drawn largest-first pass",
+ document(big, small, TICKS,
+ honest_block([("A", 300, 0.5, 225),
+ ("B", 400, 3.0, 25)], focal="none")))
+ h.expect_finding(
+ "a small bubble buried under a later larger one is reported",
+ document(small, big, TICKS,
+ honest_block([("A", 300, 0.5, 225), ("B", 400, 3.0, 25)],
+ focal="none")),
+ r"painted\s+first — draw overlapping bubbles largest-first",
+ )
+ twin_a = bubble("TwinA", 200, 2.0, 100)
+ twin_b = bubble("TwinB", 205, 2.05, 100)
+ h.expect_clean(
+ "equal-radius overlap passes in either order — order cannot bury a tie",
+ document(twin_a, twin_b, TICKS,
+ honest_block([("A", 300, 0.5, 225), ("B", 400, 3.0, 25)],
+ focal="none")),
+ )
+
+ # ── Axis scale lies ───────────────────────────────────────────────────
+ # Seven honest peers, so the leave-one-out Theil-Sen fit for each of them
+ # stays clean even with the one liar included: with four bubbles a single
+ # nudge contaminated the median and dragged a second, honest bubble past
+ # tolerance — a cascade blaming the wrong mark.
+ HONEST_PEERS = [("Edge", 100, 1.0, 400), ("Queue", 200, 2.0, 100),
+ ("Batch", 300, 0.5, 225), ("Webhook", 400, 3.0, 25),
+ ("Cache", 250, 0.8, 300), ("Ingest", 350, 2.4, 150),
+ ("Ledger", 450, 1.2, 60)]
+ nudged = honest_block(HONEST_PEERS, focal="none")
+ nudged += bubble("Stream", 150, 1.5, 900, drawn_cx=cx(150) + 8)
+ h.expect_only_one(
+ "one bubble nudged 8px in x is one finding, not a cascade",
+ document(nudged, TICKS),
+ r"bubble 'Stream' declares x=150 .* never nudge",
+ )
+ nudged_y = honest_block(HONEST_PEERS, focal="none")
+ nudged_y += bubble("Stream", 150, 1.5, 900, drawn_cy=cy(1.5) - 6)
+ h.expect_finding(
+ "one bubble nudged 6px in y is reported against its peers' scale",
+ document(nudged_y, TICKS),
+ r"bubble 'Stream' declares y=1\.5",
+ )
+ h.expect_finding(
+ "an axis where every bubble declares the same value is unverifiable",
+ document(honest_block([("A", 100, 1.0, 400), ("B", 100, 2.0, 100),
+ ("C", 100, 0.5, 225), ("D", 100, 3.0, 25)],
+ focal="none"), TICKS),
+ r"the x axis has no two distinct declared values",
+ )
+ # The lone bubble whose peers all share one value: its leave-one-out fit is
+ # degenerate, so the residual test skips it and the full-set fit passes
+ # through wherever it was drawn. Skipping silently let a bubble at a wrong
+ # coordinate ship clean; it must be reported as unverifiable instead.
+ lone = honest_block([("A", 100, 1.0, 400), ("B", 100, 2.0, 100),
+ ("C", 100, 0.5, 225)], focal="none")
+ lone += bubble("Lone", 300, 3.0, 25, drawn_cx=cx(300) + 40)
+ h.expect_finding(
+ "a bubble holding the only distinct x value is reported, not skipped",
+ document(lone, TICKS),
+ r"bubble 'Lone' holds the only distinct x value",
+ )
+
+ # ── Area lies ─────────────────────────────────────────────────────────
+ proportional = ""
+ for name, x, y, size in sorted(BUBBLES, key=lambda row: -row[3]):
+ proportional += bubble(name, x, y, size, drawn_r=round(size / 22.0, 1))
+ h.expect_finding(
+ "radius-proportional sizing is reported — r^2/size is not constant",
+ document(proportional, TICKS),
+ r"area must be proportional to the value",
+ )
+ inflated = honest_block([("Edge", 100, 1.0, 400), ("Queue", 200, 2.0, 100),
+ ("Batch", 300, 0.5, 225), ("Webhook", 400, 3.0, 25)],
+ focal="none")
+ inflated += bubble("Stream", 150, 1.5, 900, drawn_r=radius(900) + 6)
+ h.expect_finding(
+ "one bubble drawn 6px too large is reported by name",
+ document(inflated, TICKS),
+ r"bubble 'Stream' declares size 900",
+ )
+ h.expect_finding(
+ "a non-positive size is reported, not scaled",
+ document(honest_block(focal="none"),
+ bubble("Void", 250, 2.5, 0), TICKS),
+ r"area cannot encode a non-positive value",
+ )
+ h.expect_finding(
+ "a zero radius is reported — a bubble with no area states no value",
+ document(honest_block(focal="none"),
+ bubble("Flat", 250, 2.5, 300, drawn_r=0), TICKS),
+ r"a bubble with no area states no value",
+ )
+
+ # ── Focal discipline ──────────────────────────────────────────────────
+ two_accents = honest_block() + bubble("Rogue", 250, 2.5, 300,
+ stroke="#eb6c36")
+ h.expect_finding(
+ "a second accent bubble is reported — one focal claim per figure",
+ document(two_accents, TICKS),
+ r"one accent bubble max",
+ )
+ dark_accent = honest_block() + bubble("Rogue", 250, 2.5, 300,
+ stroke="#f08a59")
+ h.expect_finding(
+ "the dark-skin accent counts toward the same limit",
+ document(dark_accent, TICKS),
+ r"one accent bubble max",
+ )
+
+ # ── Label lies ────────────────────────────────────────────────────────
+ h.expect_clean(
+ "a small-caps label matches its mixed-case binding",
+ document(honest_block(), TICKS, label("Edge", cx(100), cy(1.0) - 34)),
+ )
+ h.expect_finding(
+ "a label naming an undeclared bubble is reported",
+ document(honest_block(), TICKS, label("Phantom", 500, 250)),
+ r"which no circle declares",
+ )
+ h.expect_finding(
+ "a label whose text disagrees with its binding is reported",
+ document(honest_block(), TICKS,
+ label("Edge", cx(100), cy(1.0) - 34, text="QUEUE")),
+ r"the visible text and the binding must agree",
+ )
+ h.expect_finding(
+ "a label drawn nearer another bubble renames the mark",
+ document(honest_block(), TICKS,
+ label("Edge", cx(200), cy(2.0) - 4)),
+ r"nearer 'Queue'",
+ )
+ h.expect_finding(
+ "a second label for one bubble is reported",
+ document(honest_block(), TICKS,
+ label("Edge", cx(100), cy(1.0) - 34),
+ label("Edge", cx(100), cy(1.0) + 42)),
+ r"one bubble, one label",
+ )
+
+ # ── Tick lies ─────────────────────────────────────────────────────────
+ h.expect_finding(
+ "a tick printing a different number than it declares is reported",
+ document(honest_block(), tick("x", 0), tick("x", 500, shown="400"),
+ tick("y", 0), tick("y", 4)),
+ r"prints '400' but declares 500",
+ )
+ h.expect_finding(
+ "a tick drawn off the scale the bubbles set is reported",
+ document(honest_block(), tick("x", 0), tick("x", 500, position=760),
+ tick("y", 0), tick("y", 4)),
+ r"the printed axis and the drawn positions disagree",
+ )
+ h.expect_finding(
+ "an axis with fewer than two bound ticks is reported",
+ document(honest_block(), tick("x", 0), tick("x", 500), tick("y", 0)),
+ r"the y axis binds 1 distinct tick value",
+ )
+ h.expect_finding(
+ "a tick with no data-value is reported as unbound",
+ document(honest_block(), tick("x", 0), tick("x", 500, declared="omit"),
+ tick("y", 0), tick("y", 4)),
+ r"no readable data-value",
+ )
+ h.expect_finding(
+ "a tick printing two numeric tokens is ambiguous, not first-token-parsed",
+ document(honest_block(), tick("x", 0),
+ tick("x", 500, shown="500 (512,000)"),
+ tick("y", 0), tick("y", 4)),
+ r"prints more than one numeric token",
+ )
+
+ # ── Transforms ────────────────────────────────────────────────────────
+ h.expect_finding(
+ "a transform on a bubble is rejected, not resolved",
+ document(honest_block(),
+ bubble("Slid", 250, 2.5, 300,
+ extra='transform="translate(0 80)"'), TICKS),
+ r"bubble 'Slid' carries transform=",
+ )
+ h.expect_finding(
+ "an ancestor transform moves every mark inside it",
+ document(' \n', honest_block(),
+ " \n", TICKS),
+ r"an ancestor /