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>
280 lines
10 KiB
Python
280 lines
10 KiB
Python
#!/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())
|