mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:07:15 +01:00
awesome-mcp-explorer needs six mutually distinguishable colours for its A-F
grades, and the design system had no answer -- one accent, and semantic roles
that deliberately cluster in one hue wedge. Raised as its own decision (per
PHASE3-PROMPT) and resolved by designing the ramp rather than mapping grades
onto success/warning/danger, which would have collapsed six steps into three.
Chosen by search, not by eye, under four hard constraints:
- pairwise CIE dE >= 20 under normal vision AND deuteranopia, protanopia and
tritanopia (worst observed 26.0)
- >= 3:1 against BOTH backgrounds, since the ramp does not re-theme. This is
the binding constraint: such colours only exist in L* 44-59.
- monotonic lightness 58 -> 44, so hue and lightness encode redundantly and
the ramp survives greyscale as an ordinal scale
- muted saturation, to sit with the rest of the palette
scripts/ramp_check.py re-runs the whole proof and exits non-zero on a
regression, so this is a gate rather than a paragraph. A seventh hue is not
available (best candidate reaches dE 17.9); group the tail into "other".
This ramp is the only token group that leaves the warm wedge, deliberately:
hue separation is the entire point of a categorical scale.
Also: tokens.test.ts guards every frozen value against drift, and
tokens.md was split (screen-size.md) to hold the 250-line cap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
147 lines
4.7 KiB
Python
147 lines
4.7 KiB
Python
"""Verify a categorical ramp: mutual distinguishability + CVD safety + contrast.
|
|
|
|
Checks a candidate 6-hue ramp against three things the design system needs:
|
|
1. pairwise CIE2000 separation (normal vision, and simulated CVD)
|
|
2. WCAG contrast against both theme backgrounds
|
|
3. monotonic lightness, so hue and lightness encode redundantly
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import itertools
|
|
from dataclasses import dataclass
|
|
|
|
# --- colour maths (sRGB -> linear -> XYZ -> Lab), no third-party deps --------
|
|
|
|
|
|
def hex_to_rgb(value: str) -> tuple[float, float, float]:
|
|
v = value.lstrip("#")
|
|
return tuple(int(v[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value]
|
|
|
|
|
|
def _linear(c: float) -> float:
|
|
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
|
|
|
|
def relative_luminance(value: str) -> float:
|
|
r, g, b = (_linear(c) for c in hex_to_rgb(value))
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
|
|
|
|
def contrast(a: str, b: str) -> float:
|
|
la, lb = relative_luminance(a), relative_luminance(b)
|
|
hi, lo = max(la, lb), min(la, lb)
|
|
return (hi + 0.05) / (lo + 0.05)
|
|
|
|
|
|
def to_lab(value: str) -> tuple[float, float, float]:
|
|
r, g, b = (_linear(c) for c in hex_to_rgb(value))
|
|
x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
|
|
y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
|
|
|
|
def f(t: float) -> float:
|
|
return t ** (1 / 3) if t > 0.008856 else 7.787 * t + 16 / 116
|
|
|
|
fx, fy, fz = f(x), f(y), f(z)
|
|
return 116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)
|
|
|
|
|
|
def delta_e(a: str, b: str) -> float:
|
|
"""CIE76 delta-E. Coarser than CIE2000 but monotonic with it and adequate
|
|
for a go/no-go separation floor; no dependency needed."""
|
|
la, aa, ba = to_lab(a)
|
|
lb, ab, bb = to_lab(b)
|
|
return ((la - lb) ** 2 + (aa - ab) ** 2 + (ba - bb) ** 2) ** 0.5
|
|
|
|
|
|
# --- colour-vision-deficiency simulation (Brettel/Vienot-style matrices) -----
|
|
|
|
CVD_MATRICES = {
|
|
"deuteranopia": ((0.625, 0.375, 0.0), (0.7, 0.3, 0.0), (0.0, 0.3, 0.7)),
|
|
"protanopia": ((0.567, 0.433, 0.0), (0.558, 0.442, 0.0), (0.0, 0.242, 0.758)),
|
|
"tritanopia": ((0.95, 0.05, 0.0), (0.0, 0.433, 0.567), (0.0, 0.475, 0.525)),
|
|
}
|
|
|
|
|
|
def simulate(value: str, kind: str) -> str:
|
|
r, g, b = hex_to_rgb(value)
|
|
m = CVD_MATRICES[kind]
|
|
out = []
|
|
for row in m:
|
|
c = row[0] * r + row[1] * g + row[2] * b
|
|
out.append(max(0, min(255, round(c * 255))))
|
|
return "#{:02x}{:02x}{:02x}".format(*out)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Result:
|
|
ok: bool
|
|
detail: str
|
|
|
|
|
|
def check(ramp: dict[str, str], *, floor: float = 20.0) -> list[Result]:
|
|
names = list(ramp)
|
|
results: list[Result] = []
|
|
|
|
# 1. pairwise separation, normal vision and each CVD type
|
|
for kind in ("normal", *CVD_MATRICES):
|
|
worst, pair = 999.0, ("", "")
|
|
for a, b in itertools.combinations(names, 2):
|
|
ca, cb = ramp[a], ramp[b]
|
|
if kind != "normal":
|
|
ca, cb = simulate(ca, kind), simulate(cb, kind)
|
|
d = delta_e(ca, cb)
|
|
if d < worst:
|
|
worst, pair = d, (a, b)
|
|
results.append(
|
|
Result(
|
|
worst >= floor,
|
|
f"{kind:<13} worst pair {pair[0]}/{pair[1]}: dE={worst:5.1f} "
|
|
f"(floor {floor})",
|
|
)
|
|
)
|
|
|
|
# 2. contrast against both theme backgrounds
|
|
for bg, label in (("#211d1b", "dark bg"), ("#f6f4f3", "light bg")):
|
|
worst, who = 99.0, ""
|
|
for name, value in ramp.items():
|
|
c = contrast(value, bg)
|
|
if c < worst:
|
|
worst, who = c, name
|
|
results.append(
|
|
Result(worst >= 3.0, f"{label:<13} worst {who}: {worst:4.2f}:1 (floor 3.0)")
|
|
)
|
|
|
|
# 3. monotonic lightness, so the ramp survives greyscale
|
|
lightness = [to_lab(v)[0] for v in ramp.values()]
|
|
mono = all(x > y for x, y in zip(lightness, lightness[1:])) or all(
|
|
x < y for x, y in zip(lightness, lightness[1:])
|
|
)
|
|
results.append(
|
|
Result(mono, "lightness " + " ".join(f"{x:.0f}" for x in lightness))
|
|
)
|
|
return results
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
# The shipped ramp (see ../tokens.md). Ordered by descending lightness so
|
|
# hue and lightness encode redundantly, which is what lets it carry an
|
|
# ordinal scale (grades A..F) as well as an unordered one.
|
|
RAMP = {
|
|
"cat-1": "#C57293",
|
|
"cat-2": "#398FC0",
|
|
"cat-3": "#C85A32",
|
|
"cat-4": "#228736",
|
|
"cat-5": "#8D58BB",
|
|
"cat-6": "#686D2C",
|
|
}
|
|
failures = 0
|
|
for r in check(RAMP):
|
|
print(("PASS " if r.ok else "FAIL ") + r.detail)
|
|
failures += not r.ok
|
|
print(f"\n{failures} failing check(s)")
|
|
sys.exit(1 if failures else 0)
|