fix(examples): stop label masks being clipped by node fills (#46)

Moves six clipped labels onto free connector segments and adds scripts/verify-geometry.py with tests, CI wiring, ADR 0005, and SKILL.md rule 6 so the defect cannot recur. Fixes #45.
This commit is contained in:
kira-ariaki
2026-08-12 21:50:16 -07:00
committed by GitHub
parent f928399e20
commit 2d4697446b
13 changed files with 365 additions and 22 deletions
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Adversarial tests for the label geometry verifier (verify-geometry.py)."""
from __future__ import annotations
import importlib.util
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VERIFIER = ROOT / "scripts/verify-geometry.py"
ASSET_DIR = ROOT / "skills/diagram-design/assets"
ARCHITECTURE = ASSET_DIR / "example-architecture.html"
SWIMLANE = ASSET_DIR / "example-swimlane.html"
ZONED = ASSET_DIR / "example-dp-integration.html"
def load_verifier():
spec = importlib.util.spec_from_file_location("verify_geometry", VERIFIER)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
SVG_HEAD = (
'<svg viewBox="0 0 400 200" xmlns="http://www.w3.org/2000/svg" role="img" '
'aria-labelledby="t-title t-desc">'
"<title id=\"t-title\">T</title><desc id=\"t-desc\">T.</desc>"
)
def document(body: str) -> str:
return f"<!DOCTYPE html><html><body>{SVG_HEAD}{body}</svg></body></html>"
def main() -> int:
module = load_verifier()
failures: list[str] = []
def check(label: str, source: str, expect_findings: int) -> None:
with tempfile.TemporaryDirectory() as scratch:
candidate = Path(scratch) / "candidate.html"
candidate.write_text(source, encoding="utf-8")
findings = module.check(candidate)
if len(findings) != expect_findings:
failures.append(
f"{label}: expected {expect_findings} finding(s), got "
f"{len(findings)}: {findings}"
)
else:
print(f"OK: {label}")
def check_file(label: str, path: Path, expect_findings: int) -> None:
findings = module.check(path)
if len(findings) != expect_findings:
failures.append(
f"{label}: expected {expect_findings} finding(s), got "
f"{len(findings)}: {findings}"
)
else:
print(f"OK: {label}")
node = '<rect x="100" y="60" width="160" height="64" rx="6" fill="#f5f5f5"/>'
badge = '<rect x="108" y="68" width="32" height="12" rx="2" fill="#f5f5f5"/>'
zone = '<rect x="80" y="40" width="240" height="120" rx="8" fill="#eee"/>'
# A label mask straddling a node declared later is the defect being caught.
check(
"mask clipped by a later node",
document('<rect x="240" y="80" width="48" height="12" rx="2" fill="#f5f5f5"/>' + node),
1,
)
# Same geometry, but the node is painted first, so the label stays on top.
check(
"mask over an earlier node is legal",
document(node + '<rect x="240" y="80" width="48" height="12" rx="2" fill="#f5f5f5"/>'),
0,
)
# A badge chip fully inside its own node is legal regardless of order.
check("badge chip inside a node", document(badge + node), 0)
# A zone eyebrow overlaps the zone container, which is painted first.
check(
"zone eyebrow on a zone container",
document(zone + '<rect x="160" y="34" width="60" height="12" rx="2" fill="#f5f5f5"/>'),
0,
)
# A mask entirely in open canvas is legal.
check(
"mask clear of every node",
document('<rect x="10" y="10" width="48" height="12" rx="2" fill="#f5f5f5"/>' + node),
0,
)
# Touching within the 1px tolerance is not a finding.
check(
"mask abutting a node edge",
document('<rect x="52" y="80" width="48" height="12" rx="2" fill="#f5f5f5"/>' + node),
0,
)
check_file("shipped architecture example", ARCHITECTURE, 0)
check_file("shipped swimlane example", SWIMLANE, 0)
check_file("shipped zoned example", ZONED, 0)
if failures:
print("\nFAILURES:")
for failure in failures:
print(f" - {failure}")
return 1
print("All label geometry tests passed.")
return 0
if __name__ == "__main__":
sys.exit(main())
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Verify no diagram label mask is clipped by a node painted after it.
SKILL.md §6 keeps an arrow label 6-10px clear of its connector, and §5 fixes the
paint order as background -> zones -> arrows -> labels -> nodes. Nothing keeps a
label mask off a *node*, so a label whose mask lands partly inside a node
rectangle that is painted later gets covered by the node fill: the text renders
as a fragment sitting on the node border.
Paint order is what makes this a defect rather than a stylistic choice:
* A mask overlapping a zone container is fine - zones are painted before labels,
so the label stays on top. Zone eyebrows rely on this.
* A mask overlapping a node declared *later* in the document is clipped by that
node. That is the failure this check reports.
Shape heuristics follow the shipped templates:
* A node is a `<rect>` at least 60x40 - large enough for a title and sublabel.
* A label mask is a `<rect>` 20-120 wide and 8-14 tall - the masking plate that
SKILL.md prescribes for arrow labels and zone eyebrows.
* A mask fully contained in a node is a badge chip (`EXT`, `EDGE`, `ORIG`) and
is legal.
Usage:
python3 scripts/verify-geometry.py --all
python3 scripts/verify-geometry.py skills/diagram-design/assets/example-x.html
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ASSET_DIR = ROOT / "skills/diagram-design/assets"
RECT_RE = re.compile(
r"<rect\b[^>]*?"
r'\bx="(?P<x>-?[\d.]+)"\s+'
r'y="(?P<y>-?[\d.]+)"\s+'
r'width="(?P<w>[\d.]+)"\s+'
r'height="(?P<h>[\d.]+)"',
re.IGNORECASE,
)
NODE_MIN_W = 60.0
NODE_MIN_H = 40.0
MASK_MIN_W = 20.0
MASK_MAX_W = 120.0
MASK_MIN_H = 8.0
MASK_MAX_H = 14.0
EPSILON = 0.5
class Rect:
__slots__ = ("x", "y", "w", "h", "line", "offset")
def __init__(self, x, y, w, h, line, offset) -> None:
self.x, self.y, self.w, self.h = x, y, w, h
self.line, self.offset = line, offset
@property
def right(self) -> float:
return self.x + self.w
@property
def bottom(self) -> float:
return self.y + self.h
def __repr__(self) -> str:
return f"({self.x:g},{self.y:g} {self.w:g}x{self.h:g})"
def parse_rects(source: str) -> list[Rect]:
rects: list[Rect] = []
for match in RECT_RE.finditer(source):
rects.append(
Rect(
float(match.group("x")),
float(match.group("y")),
float(match.group("w")),
float(match.group("h")),
source.count("\n", 0, match.start()) + 1,
match.start(),
)
)
return rects
def overlap(a: Rect, b: Rect) -> tuple[float, float]:
return (
min(a.right, b.right) - max(a.x, b.x),
min(a.bottom, b.bottom) - max(a.y, b.y),
)
def contained(inner: Rect, outer: Rect) -> bool:
return (
inner.x >= outer.x - EPSILON
and inner.y >= outer.y - EPSILON
and inner.right <= outer.right + EPSILON
and inner.bottom <= outer.bottom + EPSILON
)
def check(path: Path) -> list[str]:
source = path.read_text(encoding="utf-8")
rects = parse_rects(source)
nodes = [r for r in rects if r.w >= NODE_MIN_W and r.h >= NODE_MIN_H]
masks = [
r
for r in rects
if MASK_MIN_W <= r.w <= MASK_MAX_W and MASK_MIN_H <= r.h <= MASK_MAX_H
]
findings: list[str] = []
for mask in masks:
for node in nodes:
if node.offset <= mask.offset:
continue # painted before the label; the label stays on top
dx, dy = overlap(mask, node)
if dx <= 1.0 or dy <= 1.0 or contained(mask, node):
continue
findings.append(
f"{path.name}:{mask.line}: label mask {mask} is clipped by node "
f"{node} declared later at line {node.line} (overlap {dx:g}x{dy:g}px)"
f" - move the label onto a free segment of its connector"
)
break
return findings
def targets(args: argparse.Namespace) -> list[Path]:
if args.all:
return sorted(ASSET_DIR.glob("*.html"))
return [Path(p) for p in args.files]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("files", nargs="*", help="HTML diagrams to check")
parser.add_argument("--all", action="store_true", help="check every shipped asset")
args = parser.parse_args()
paths = targets(args)
if not paths:
parser.error("pass one or more files, or --all")
findings: list[str] = []
for path in paths:
if not path.exists():
findings.append(f"{path}: file not found")
continue
findings.extend(check(path))
for finding in findings:
print(finding)
print(f"Summary: {len(paths)} file(s) checked, {len(findings)} finding(s).")
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())