feat(bar): add dumbbell as a Bar variant (#107)

Documents the dumbbell in type-bar.md's existing Variants section,
alongside grouped and stacked bars. Reference-only, matching the
precedent those two set and the one #100 landed for datalake: no
example assets, no gallery entries.

Not a new visual type. The count stays at 28 and SKILL.md is untouched
-- no selection-table row, no frontmatter hook, no manifest description
change.

Covers layout on the shared 1000x500 composition, an element pattern,
and five honesty rules: no truncated axis, one scale with both endpoints
labelled, the connector is a gap and not a trajectory, a stated row
order, and no imputed or silently dropped endpoints.

Two of those rules live in a formula rather than a drawing, so they ship
as a checker with adversarial fixtures per ADR 0005:

- Domain resolution is exhaustive over sign. A rule phrased as
  "all-positive / all-negative" silently drops data that only touches
  zero, and drops all-zero data entirely -- where floor == ceil == 0 and
  the position formula divides by zero. resolve_domain() partitions the
  space, guarantees ceil > floor, and returns a finite fallback span for
  all-zero input so every dot lands on the floor.
- Non-text contrast is carried by the boundary, not the fill. Accent on
  paper is 2.86:1 skin-wide, and shape redundancy does not waive WCAG
  1.4.11, so the solid endpoint takes a 1px ink stroke (11.82:1) and the
  connector 55% ink on light / 40% on dark (3.19:1 and 3.24:1). The
  checker reads these tokens out of the reference, so prose and
  thresholds cannot drift apart.

scripts/test-verify-dumbbell.py covers both polarities: all-zero,
zero-touching, mixed-sign, empty and non-finite input, observed-extremes
truncation, and the sub-3:1 treatments this replaces. Wired into CI and
CONTRIBUTING alongside the treemap and geometry gates.

Other things the geometry and house style forced: the focal bar pattern
(12% tint plus accent stroke) does not survive at a 6px dot, so the pair
separates by fill weight; a 64px row pitch from y=96 overflows the y=420
plot floor at seven rows, so pitch and origin are tabulated per row
count; rgb(45,49,66) IS the dark paper, so light-theme hairlines
composite to 1.000:1 and must invert; label offsets keyed to start/end
put both labels inside the pair on a decreasing row; a value on the
domain floor collided with its category label; and the value axis was
missing the Geist Mono 7px title every other cartesian type carries.

Co-authored-by: 0xDarkMatter <0xDarkMatter@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
0xDarkMatter
2026-08-18 18:49:39 -05:00
committed by GitHub
co-authored by 0xDarkMatter Claude Opus 5
parent 0947fdb6e5
commit 29917724f8
7 changed files with 564 additions and 4 deletions
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Adversarial tests for verify-dumbbell.py - both polarities.
A checker that only proves the good cases pass is half a checker: it cannot
tell you it would have caught the bug. Every rule below is tested twice, once
for the case that must pass and once for the case that must be rejected.
The zero cases are the reason this file exists. A sign-based domain rule reads
naturally and silently omits two inputs - data that only touches zero, and data
that is entirely zero - and the second one divides by zero in the position
formula. Both are exercised through the real scaling path here, not asserted in
prose.
Usage:
python3 scripts/test-verify-dumbbell.py
Exit: 0 all passed, 1 a case behaved wrongly.
"""
from __future__ import annotations
import importlib.util
import math
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CHECKER = ROOT / "scripts/verify-dumbbell.py"
def _load_checker():
"""Import the checker despite its hyphenated filename."""
spec = importlib.util.spec_from_file_location("verify_dumbbell", CHECKER)
if spec is None or spec.loader is None:
raise RuntimeError("cannot load %s" % CHECKER)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
verify_dumbbell = _load_checker()
resolve_domain = verify_dumbbell.resolve_domain
scale = verify_dumbbell.scale
contrast = verify_dumbbell.contrast
composite = verify_dumbbell.composite
is_truncated = verify_dumbbell.is_truncated
DomainError = verify_dumbbell.DomainError
FAILURES = []
def ok(condition, label):
if condition:
sys.stdout.write("OK: %s\n" % label)
else:
FAILURES.append(label)
sys.stdout.write("FAIL: %s\n" % label)
# === DOMAIN - the cases a sign-based rule drops =============================
def test_all_zero_is_finite():
"""The case that divides by zero if the rule stops at 'positive/negative'."""
floor, ceiling = resolve_domain([0, 0, 0, 0])
ok(ceiling > floor, "all-zero data yields a non-degenerate domain")
coordinates = [scale(v, floor, ceiling) for v in (0, 0, 0, 0)]
ok(all(math.isfinite(x) for x in coordinates),
"all-zero data produces finite coordinates")
ok(all(x == verify_dumbbell.PLOT_X0 for x in coordinates),
"all-zero data puts every dot on the floor, which is the truth")
def test_zero_touching_domains():
floor, ceiling = resolve_domain([0, 5, 12])
ok((floor, ceiling) == (0.0, 20.0),
"positive data touching zero anchors the floor at zero")
floor, ceiling = resolve_domain([-12, -5, 0])
ok((floor, ceiling) == (-20.0, 0.0),
"negative data touching zero anchors the ceiling at zero")
for values in ([0, 5, 12], [-12, -5, 0]):
floor, ceiling = resolve_domain(values)
ok(all(math.isfinite(scale(v, floor, ceiling)) for v in values),
"zero-touching %s scales finitely" % ("positive" if values[-1] else "negative"))
def test_sign_cases_resolve():
ok(resolve_domain([18, 66, 71]) == (0.0, 100.0),
"all-positive data anchors the floor at zero")
ok(resolve_domain([-71, -18]) == (-100.0, 0.0),
"all-negative data anchors the ceiling at zero")
floor, ceiling = resolve_domain([-20, 5, 40])
ok(floor < 0 < ceiling, "data crossing zero brackets both sides")
def test_identical_and_tiny_values():
floor, ceiling = resolve_domain([7, 7])
ok(ceiling > floor and math.isfinite(scale(7, floor, ceiling)),
"a pair of identical non-zero values still scales")
floor, ceiling = resolve_domain([0.0004, 0.0009])
ok(ceiling > floor, "sub-unit magnitudes still yield a usable domain")
def test_empty_and_non_finite_rejected():
"""The other polarity: garbage in must raise, not return a silent domain."""
for label, values in (("empty input", []),
("NaN in input", [1.0, float("nan")]),
("infinity in input", [1.0, float("inf")])):
try:
resolve_domain(values)
except DomainError:
ok(True, "%s is rejected" % label)
else:
ok(False, "%s was NOT rejected" % label)
def test_truncated_bounds_are_detected():
"""The honesty rule, executable: observed min/max must read as truncation."""
values = [18, 66, 34, 71]
ok(is_truncated(values, 18.0, 71.0),
"bounds taken from the observed extremes are reported as truncation")
resolved = resolve_domain(values)
ok(not is_truncated(values, *resolved),
"correctly resolved bounds are NOT reported as truncation")
def test_scaling_preserves_gap_ratios():
"""Two rows on one scale: drawn gaps must stay proportional to real ones."""
values = [10, 30, 40, 80]
floor, ceiling = resolve_domain(values)
gap_a = scale(30, floor, ceiling) - scale(10, floor, ceiling)
gap_b = scale(80, floor, ceiling) - scale(40, floor, ceiling)
ok(abs(gap_b / gap_a - 2.0) < 1e-9,
"a doubled data gap draws exactly twice as wide")
# === CONTRAST - both polarities =============================================
def test_documented_marks_clear_the_threshold():
for label, ink, alpha, paper in verify_dumbbell.CONNECTORS:
ratio = contrast(composite(ink, alpha, paper), paper)
ok(ratio >= 3.0, "%s clears 3:1 (%.3f:1)" % (label, ratio))
for label, stroke, paper in verify_dumbbell.ENDPOINT_STROKES:
ratio = contrast(stroke, paper)
ok(ratio >= 3.0, "%s clears 3:1 (%.3f:1)" % (label, ratio))
def test_rejected_treatments_are_caught():
"""The other polarity: the treatments this PR replaced must still fail."""
hairline = composite("#2d3142", 0.25, "#f5f5f5")
ok(contrast(hairline, "#f5f5f5") < 3.0,
"the original 25%% hairline connector is correctly under 3:1")
ok(contrast("#eb6c36", "#f5f5f5") < 3.0,
"an unstroked accent endpoint is correctly under 3:1 on light paper")
ok(contrast("#7a8399", "#f5f5f5") < 4.5,
"the soft token is correctly under 4.5:1 and unusable for text")
def test_checker_fails_closed_on_a_stripped_reference(tmp_path: Path):
empty = tmp_path / "type-bar.md"
empty.write_text("# Bar\n\nNo variants here.\n", encoding="utf-8")
findings = verify_dumbbell.check_reference(empty)
ok(bool(findings), "a reference with no dumbbell section is a finding, not a pass")
missing = tmp_path / "partial.md"
missing.write_text("# Bar\n\n## Variants\n\n- Dumbbell: floor and ceil.\n",
encoding="utf-8")
findings = verify_dumbbell.check_reference(missing)
ok(bool(findings),
"a dumbbell section missing the connector tokens is a finding")
absent = tmp_path / "nope.md"
ok(bool(verify_dumbbell.check_reference(absent)),
"a missing reference file is a finding, not a pass")
def test_shipped_reference_passes():
findings = verify_dumbbell.check_reference(verify_dumbbell.REFERENCE)
ok(not findings,
"the shipped reference documents every token the checker verifies")
def main() -> int:
import tempfile
test_all_zero_is_finite()
test_zero_touching_domains()
test_sign_cases_resolve()
test_identical_and_tiny_values()
test_empty_and_non_finite_rejected()
test_truncated_bounds_are_detected()
test_scaling_preserves_gap_ratios()
test_documented_marks_clear_the_threshold()
test_rejected_treatments_are_caught()
with tempfile.TemporaryDirectory() as directory:
test_checker_fails_closed_on_a_stripped_reference(Path(directory))
test_shipped_reference_passes()
if FAILURES:
sys.stderr.write("\n%d case(s) behaved wrongly:\n" % len(FAILURES))
for failure in FAILURES:
sys.stderr.write(" - %s\n" % failure)
return 1
sys.stdout.write("\nAll dumbbell checker tests passed\n")
return 0
if __name__ == "__main__":
sys.exit(main())
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""Verify the two dumbbell rules that live in a formula rather than in a drawing.
Both rules in `references/type-bar.md` are formula-level: a generator implements
them arithmetically, and both fail in ways that render perfectly. Prose alone
cannot defend either, so both are executable here and the reference is checked
against this module rather than the other way round.
1. DOMAIN RESOLUTION - the axis bounds are a function of the data's range, never
of its observed extremes. Taking `min`/`max` as the bounds IS the truncation
the reference forbids: the plot width is fixed, so a narrowed domain raises
px-per-unit and draws every gap wider. The subtle case is zero. A sign-based
rule that says "all-positive" and "all-negative" silently omits data that
only touches zero, and omits the all-zero dataset entirely - where the naive
reading yields `floor == ceil == 0` and the position formula divides by zero.
`resolve_domain` partitions the space exhaustively and guarantees
`ceil > floor`, so coordinates are always finite.
2. NON-TEXT CONTRAST - the solid endpoint and the connector are the marks that
carry "which series" and "which pair". WCAG 1.4.11 asks 3:1 of a graphical
object required to understand the content, and shape redundancy does not
waive it: a reader still has to see the solid mark's boundary and the line
joining the pair. Accent-on-paper is 2.86:1 skin-wide and cannot carry that,
so the boundary is carried by a stroke and the connector by an alpha that
clears 3:1 in both themes. The thresholds are checked here against the tokens
the reference actually documents, so the two cannot drift apart.
The check FAILS CLOSED. A reference this cannot parse, or a token it cannot
find, is a finding - never a silent pass.
Usage:
python3 scripts/verify-dumbbell.py
python3 scripts/verify-dumbbell.py --reference path/to/type-bar.md
Exit: 0 clean, 1 findings, 2 usage.
"""
from __future__ import annotations
import argparse
import math
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
REFERENCE = ROOT / "skills/diagram-design/references/type-bar.md"
# --- composition constants, quoted from the reference -----------------------
PLOT_X0 = 200 # left edge of the plot area
PLOT_WIDTH = 760 # 200 -> 960
MIN_GAP_PX = 16 # below this two r=6 marks read as one blob
# All-zero data has no range to scale against. A finite fallback span keeps the
# position formula defined; every dot lands on the floor, which is the truth.
ALL_ZERO_SPAN = 1.0
# --- skin tokens ------------------------------------------------------------
PAPER_LIGHT, PAPER_DARK = "#f5f5f5", "#2d3142"
WCAG_NON_TEXT = 3.0 # 1.4.11, graphical objects
# (label, ink hex, alpha, paper) - the connector in each theme
CONNECTORS = [
("connector (light)", "#2d3142", 0.55, PAPER_LIGHT),
("connector (dark)", "#f5f5f5", 0.40, PAPER_DARK),
]
# (label, stroke hex, paper) - the boundary of the solid endpoint
ENDPOINT_STROKES = [
("solid endpoint stroke (light)", "#2d3142", PAPER_LIGHT),
("solid endpoint stroke (dark)", "#f5f5f5", PAPER_DARK),
]
# Tokens the reference must actually name, so prose and code cannot drift.
REQUIRED_TOKENS = [
"rgba(45,49,66,0.55)",
"rgba(245,245,245,0.40)",
]
class DomainError(ValueError):
"""Raised when a domain cannot be resolved at all."""
# === DOMAIN =================================================================
def _nice_magnitude(value: float) -> float:
"""Smallest round number >= |value|, from the 1 / 2 / 2.5 / 5 / 10 ladder."""
magnitude = abs(value)
if magnitude == 0:
return 0.0
exponent = math.floor(math.log10(magnitude))
base = 10.0 ** exponent
for step in (1.0, 2.0, 2.5, 5.0, 10.0):
candidate = step * base
if candidate >= magnitude - 1e-12:
return candidate
return 10.0 * base
def resolve_domain(values):
"""Return (floor, ceil) for a dumbbell's value axis.
Exhaustive over the sign of the data, including the two cases a sign-based
rule drops: data that only touches zero, and data that is entirely zero.
Guarantees ceil > floor, so the position formula never divides by zero.
"""
numbers = [float(v) for v in values]
if not numbers:
raise DomainError("no values: a dumbbell needs at least one pair")
if any(math.isnan(v) or math.isinf(v) for v in numbers):
raise DomainError("non-finite value in input")
low, high = min(numbers), max(numbers)
if low == 0.0 and high == 0.0:
floor, ceiling = 0.0, ALL_ZERO_SPAN # every dot sits on the floor
elif low >= 0.0:
floor, ceiling = 0.0, _nice_magnitude(high)
elif high <= 0.0:
floor, ceiling = -_nice_magnitude(low), 0.0
else:
floor, ceiling = -_nice_magnitude(low), _nice_magnitude(high)
if not ceiling > floor:
raise DomainError(
"degenerate domain %r..%r from values %r" % (floor, ceiling, numbers)
)
return floor, ceiling
def scale(value: float, floor: float, ceiling: float) -> float:
"""Map a value onto the plot's x axis. Mirrors the reference's formula."""
span = ceiling - floor
if span <= 0:
raise DomainError("non-positive span %r" % (span,))
return PLOT_X0 + (float(value) - floor) / span * PLOT_WIDTH
def is_truncated(values, floor: float, ceiling: float) -> bool:
"""True when the bounds hug the data instead of following its sign.
This is the failure the honesty rule exists to prevent: bounds taken from
the observed extremes rather than resolved from the data's range.
"""
resolved_floor, resolved_ceiling = resolve_domain(values)
return (floor, ceiling) != (resolved_floor, resolved_ceiling)
# === CONTRAST ===============================================================
def _channel(component: int) -> float:
fraction = component / 255.0
if fraction <= 0.04045:
return fraction / 12.92
return ((fraction + 0.055) / 1.055) ** 2.4
def relative_luminance(color: str) -> float:
value = color.lstrip("#")
red, green, blue = (int(value[i:i + 2], 16) for i in (0, 2, 4))
return 0.2126 * _channel(red) + 0.7152 * _channel(green) + 0.0722 * _channel(blue)
def contrast(foreground: str, background: str) -> float:
first, second = relative_luminance(foreground), relative_luminance(background)
lighter, darker = max(first, second), min(first, second)
return (lighter + 0.05) / (darker + 0.05)
def composite(foreground: str, alpha: float, background: str) -> str:
"""Flatten a translucent stroke onto its background."""
fore = [int(foreground.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)]
back = [int(background.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)]
blended = [round(fore[i] * alpha + back[i] * (1 - alpha)) for i in range(3)]
return "#%02x%02x%02x" % tuple(blended)
# === CHECKS =================================================================
def check_domain_rules():
"""Exercise the scaling path over every sign case, including the zero ones."""
findings = []
cases = [
("all zero", [0, 0, 0, 0]),
("zero-touching positive", [0, 0, 5, 12]),
("zero-touching negative", [-12, -5, 0, 0]),
("all positive", [18, 66, 34, 71]),
("all negative", [-71, -34, -66, -18]),
("crossing zero", [-20, 5, 40, -3]),
("single identical pair", [7, 7]),
("tiny magnitudes", [0.0004, 0.0009]),
]
for label, values in cases:
try:
floor, ceiling = resolve_domain(values)
except DomainError as error:
findings.append("%s: domain unresolved (%s)" % (label, error))
continue
if not ceiling > floor:
findings.append("%s: ceil %r not above floor %r" % (label, ceiling, floor))
continue
for value in values:
x = scale(value, floor, ceiling)
if not math.isfinite(x):
findings.append("%s: non-finite coordinate for %r" % (label, value))
elif not (PLOT_X0 - 1e-6 <= x <= PLOT_X0 + PLOT_WIDTH + 1e-6):
findings.append(
"%s: %r maps to %.3f, outside the plot area" % (label, value, x)
)
return findings
def check_contrast_rules():
findings = []
for label, ink, alpha, paper in CONNECTORS:
ratio = contrast(composite(ink, alpha, paper), paper)
if ratio < WCAG_NON_TEXT:
findings.append(
"%s: %.3f:1 against paper, under the %.1f:1 WCAG 1.4.11 asks"
% (label, ratio, WCAG_NON_TEXT)
)
for label, stroke, paper in ENDPOINT_STROKES:
ratio = contrast(stroke, paper)
if ratio < WCAG_NON_TEXT:
findings.append(
"%s: %.3f:1 against paper, under the %.1f:1 WCAG 1.4.11 asks"
% (label, ratio, WCAG_NON_TEXT)
)
return findings
def check_reference(path: Path):
"""Fail closed: the reference must name the tokens this module verifies."""
findings = []
if not path.is_file():
return ["reference not found: %s" % path]
text = path.read_text(encoding="utf-8")
if "Dumbbell" not in text:
return ["reference %s carries no dumbbell section to check" % path.name]
for token in REQUIRED_TOKENS:
if token not in text:
findings.append(
"reference does not document %s, so the checked value is not the "
"shipped one" % token
)
if not re.search(r"\bfloor\b", text) or not re.search(r"\bceil\b", text):
findings.append("reference does not name the domain bounds the formula uses")
return findings
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
description="Verify the dumbbell's domain and non-text-contrast rules."
)
parser.add_argument(
"--reference", type=Path, default=REFERENCE,
help="path to type-bar.md (default: the shipped reference)",
)
args = parser.parse_args(argv)
findings = []
findings += check_domain_rules()
findings += check_contrast_rules()
findings += check_reference(args.reference)
if findings:
for finding in findings:
sys.stderr.write("FAIL dumbbell: %s\n" % finding)
sys.stderr.write("Summary: %d finding(s).\n" % len(findings))
return 1
sys.stdout.write(
"OK dumbbell: domain resolves finitely over every sign case "
"(including all-zero), and the documented connector and endpoint "
"boundary clear 3:1 in both themes\n"
)
return 0
if __name__ == "__main__":
sys.exit(main())