#69: audit-theme-design tags hue/CVD/ANSI-family noise on white, vantablack, lumon, hackerman, matte-black, miasma as [identity] with a legend — do not retune identity palettes into traffic lights. #70: import-palettes keeps surface≠overlay when color0==color8, derives light UI chips from base (ANSI-black trap), gentler light mantle; roles are first-class. No bulk re-import of shipped theme JSON. Verified: V0 (py_compile + synthetic import fixtures + audit report).
194 lines
8.6 KiB
Python
Executable File
194 lines
8.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Item 28 slice (a): design-theory audit of every themes/*.json.
|
|
|
|
Reports (never fails): OKLCH lightness architecture, extended WCAG
|
|
contrast pairs, hue-family sanity for status colors, accent harmony,
|
|
CVD (protanopia/deuteranopia) distinguishability, ANSI slot semantics.
|
|
|
|
Identity exemptions (#69): white, vantablack, lumon, hackerman,
|
|
matte-black, miasma deliberately reject traffic-light status hues.
|
|
Their hue / CVD / ANSI-family findings are tagged [identity] (expected
|
|
design noise), not bugs to "fix" into red/yellow/green.
|
|
"""
|
|
import json, math, sys
|
|
from pathlib import Path
|
|
|
|
# ── color math ───────────────────────────────────────────────────────
|
|
def srgb_to_linear(c):
|
|
c /= 255.0
|
|
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
|
|
def hex_rgb(h):
|
|
h = h.lstrip("#")
|
|
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
|
|
|
def hex_lin(h):
|
|
return tuple(srgb_to_linear(c) for c in hex_rgb(h))
|
|
|
|
def oklab(h):
|
|
r, g, b = hex_lin(h)
|
|
l = 0.4122214708*r + 0.5363325363*g + 0.0514459929*b
|
|
m = 0.2119034982*r + 0.6806995451*g + 0.1073969566*b
|
|
s = 0.0883024619*r + 0.2817188376*g + 0.6299787005*b
|
|
l, m, s = l ** (1/3), m ** (1/3), s ** (1/3)
|
|
return (0.2104542553*l + 0.7936177850*m - 0.0040720468*s,
|
|
1.9779984951*l - 2.4285922050*m + 0.4505937099*s,
|
|
0.0259040371*l + 0.7827717662*m - 0.8086757660*s)
|
|
|
|
def oklch(h):
|
|
L, a, b = oklab(h)
|
|
C = math.hypot(a, b)
|
|
H = math.degrees(math.atan2(b, a)) % 360
|
|
return L, C, H
|
|
|
|
def de(h1, h2): # OKLab euclidean distance
|
|
a, b = oklab(h1), oklab(h2)
|
|
return math.dist(a, b)
|
|
|
|
def wcag_lum(h):
|
|
r, g, b = hex_lin(h)
|
|
return 0.2126*r + 0.7152*g + 0.0722*b
|
|
|
|
def ratio(f, b):
|
|
hi, lo = sorted((wcag_lum(f), wcag_lum(b)), reverse=True)
|
|
return (hi + 0.05) / (lo + 0.05)
|
|
|
|
# Machado et al. 2009, severity 1.0, applied in linear RGB.
|
|
PROTAN = [(0.152286, 1.052583, -0.204868), (0.114503, 0.786281, 0.099216), (-0.003882, -0.048116, 1.051998)]
|
|
DEUTAN = [(0.367322, 0.860646, -0.227968), (0.280085, 0.672501, 0.047413), (-0.011820, 0.042940, 0.968881)]
|
|
|
|
def cvd_hex(h, M):
|
|
r, g, b = hex_lin(h)
|
|
sim = [max(0.0, min(1.0, M[i][0]*r + M[i][1]*g + M[i][2]*b)) for i in range(3)]
|
|
def enc(c):
|
|
c = 12.92*c if c <= 0.0031308 else 1.055 * c ** (1/2.4) - 0.055
|
|
return max(0, min(255, round(c*255)))
|
|
return "#%02x%02x%02x" % tuple(enc(c) for c in sim)
|
|
|
|
def hue_in(H, lo, hi):
|
|
return lo <= H <= hi if lo <= hi else (H >= lo or H <= hi)
|
|
|
|
ROLES = ["base","mantle","surface","overlay","text","subtext","muted","accent","accentAlt","good","warn","bad"]
|
|
|
|
# Themes whose identity is monochrome / mono-hue / earthy — not traffic lights.
|
|
# Hue + CVD + ANSI family noise here is expected; do not retune into R/Y/G.
|
|
IDENTITY_THEMES = {
|
|
"white": "monochrome greys — status is L-steps, not traffic lights",
|
|
"vantablack": "monochrome greys — status is L-steps, not traffic lights",
|
|
"lumon": "Severance blue mono — good/warn/bad share one blue family",
|
|
"hackerman": "matrix green mono — warn/bad live in green/cyan",
|
|
"matte-black": "desaturated material accents — not traffic-light hues",
|
|
"miasma": "earthy swamp palette — bad is brown, not red",
|
|
}
|
|
# Categories re-tagged [identity] for the themes above (structural checks stay).
|
|
IDENTITY_OK_CATS = frozenset({"hue", "cvd"})
|
|
|
|
findings = {}
|
|
def note(slug, cat, msg, *, identity_ok=False):
|
|
if identity_ok and slug in IDENTITY_THEMES and cat in IDENTITY_OK_CATS:
|
|
cat = "identity"
|
|
elif identity_ok and slug in IDENTITY_THEMES and cat == "ansi" and "hue" in msg:
|
|
# ANSI slot family mismatches on identity palettes are the same choice.
|
|
cat = "identity"
|
|
findings.setdefault(slug, []).append((cat, msg))
|
|
|
|
themes = sorted(Path(sys.argv[1] if len(sys.argv) > 1 else "themes").glob("*.json"))
|
|
for tf in themes:
|
|
t = json.loads(tf.read_text()); slug = tf.stem
|
|
c = t["colors"]; mode = t.get("mode", "dark"); dark = mode == "dark"
|
|
L = {r: oklch(c[r])[0] for r in ROLES}
|
|
C = {r: oklch(c[r])[1] for r in ROLES}
|
|
H = {r: oklch(c[r])[2] for r in ROLES}
|
|
|
|
# A. lightness architecture: bg stack monotonic away from base
|
|
stack = ["base", "surface", "overlay"]
|
|
diffs = [L[b] - L[a] for a, b in zip(stack, stack[1:])]
|
|
want = 1 if dark else -1
|
|
for (a, b), d in zip(zip(stack, stack[1:]), diffs):
|
|
if d * want < 0.005:
|
|
note(slug, "lightness", f"bg stack not raised: L({b})={L[b]:.3f} vs L({a})={L[a]:.3f} ({mode})")
|
|
# fg stack: muted < subtext < text in |L - L(base)|
|
|
dist = {r: abs(L[r] - L["base"]) for r in ("muted","subtext","text")}
|
|
if not (dist["muted"] <= dist["subtext"] + 0.005 and dist["subtext"] <= dist["text"] + 0.005):
|
|
note(slug, "lightness", f"fg hierarchy off: |dL| muted={dist['muted']:.2f} subtext={dist['subtext']:.2f} text={dist['text']:.2f}")
|
|
|
|
# B. contrast pairs (audit thresholds)
|
|
for f, b, mn, why in [
|
|
("text","base",4.5,"body text"), ("text","surface",4.5,"text on chips/menus"),
|
|
("subtext","base",3.0,"secondary text"), ("accent","base",3.0,"indicators"),
|
|
("accentAlt","base",3.0,"alt accent"), ("base","accent",3.0,"selected rows"),
|
|
("good","base",2.5,"status glyph"), ("warn","base",2.5,"status glyph"),
|
|
("bad","base",2.5,"status glyph"), ("muted","base",2.0,"dimmed text"),
|
|
]:
|
|
r = ratio(c[f], c[b])
|
|
if r < mn:
|
|
note(slug, "contrast", f"{f} on {b} = {r:.2f} (< {mn}, {why})")
|
|
|
|
# C. hue families for status roles + harmony
|
|
for role, lo, hi, fam in [("good",120,180,"green"),("warn",50,120,"yellow/orange"),("bad",0,45,"red")]:
|
|
h = H[role]
|
|
ok = hue_in(h, lo, hi) or (role == "bad" and hue_in(h, 330, 45))
|
|
if C[role] < 0.03:
|
|
note(slug, "hue", f"{role} is near-grey (C={C[role]:.3f}) — status color carries no hue",
|
|
identity_ok=True)
|
|
elif not ok:
|
|
note(slug, "hue", f"{role} hue {h:.0f}° outside {fam} family", identity_ok=True)
|
|
dh = abs(H["accent"] - H["accentAlt"]); dh = min(dh, 360 - dh)
|
|
harm = "analogous" if dh < 60 else "triadic-ish" if dh < 150 else "complementary"
|
|
if 90 <= dh <= 150 and de(c["accent"], c["accentAlt"]) > 0.12:
|
|
note(slug, "harmony", f"accent/accentAlt Δhue={dh:.0f}° (square-clash zone; {harm})")
|
|
|
|
# D. CVD distinguishability of status pairs (small glyphs)
|
|
for M, name in [(PROTAN, "protan"), (DEUTAN, "deutan")]:
|
|
for r1, r2 in [("good","bad"),("good","warn"),("accent","bad")]:
|
|
d = de(cvd_hex(c[r1], M), cvd_hex(c[r2], M))
|
|
if d < 0.09:
|
|
note(slug, "cvd", f"{r1}/{r2} nearly identical under {name} (dE={d:.03f})",
|
|
identity_ok=True)
|
|
|
|
# E. ANSI slot semantics (greeter/tty rely on them)
|
|
ansi = t.get("ansi", [])
|
|
if len(ansi) == 16:
|
|
for idx, lo, hi, fam in [(1,330,45,"red"),(2,100,180,"green"),(3,50,110,"yellow"),
|
|
(4,220,290,"blue"),(5,290,350,"magenta"),(6,160,230,"cyan")]:
|
|
for slot in (idx, idx+8):
|
|
Ls, Cs, Hs = oklch(ansi[slot])
|
|
if Cs >= 0.04 and not hue_in(Hs, lo, hi):
|
|
note(slug, "ansi", f"ansi[{slot}] hue {Hs:.0f}° not {fam}",
|
|
identity_ok=True)
|
|
if oklch(ansi[0])[0] > oklch(ansi[15])[0]:
|
|
note(slug, "ansi", "ansi[0] lighter than ansi[15] (inverted slots)")
|
|
rt = ratio(ansi[7], ansi[0])
|
|
if rt < 3.0:
|
|
note(slug, "ansi", f"ansi[7] on ansi[0] = {rt:.2f} (tuigreet text=gray on container=black)")
|
|
|
|
print(f"audited {len(themes)} themes\n")
|
|
if IDENTITY_THEMES:
|
|
print("identity exemptions (hue/CVD/ANSI-family expected, not traffic-light bugs):")
|
|
for slug in sorted(IDENTITY_THEMES):
|
|
print(f" {slug}: {IDENTITY_THEMES[slug]}")
|
|
print()
|
|
|
|
cats = {}
|
|
for slug in sorted(findings):
|
|
id_note = IDENTITY_THEMES.get(slug)
|
|
header = f"── {slug}"
|
|
if id_note:
|
|
header += f" · identity: {id_note}"
|
|
print(header)
|
|
for cat, msg in findings[slug]:
|
|
cats[cat] = cats.get(cat, 0) + 1
|
|
print(f" [{cat}] {msg}")
|
|
|
|
# clean = no findings; identity-only = only [identity] tags remain
|
|
clean = [t.stem for t in themes if t.stem not in findings]
|
|
identity_only = []
|
|
for slug, items in findings.items():
|
|
if slug in IDENTITY_THEMES and all(cat == "identity" for cat, _ in items):
|
|
identity_only.append(slug)
|
|
|
|
print(f"\nclean: {', '.join(clean) if clean else '(none)'}")
|
|
if identity_only:
|
|
print(f"identity-only (expected noise): {', '.join(sorted(identity_only))}")
|
|
print("totals:", dict(sorted(cats.items())))
|