fix(tools): identity audit exemptions + import hierarchy (#69 #70)

#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).
This commit is contained in:
Bernardo Magri
2026-07-10 09:34:12 +01:00
parent a640de4fd4
commit 34362d6a92
5 changed files with 167 additions and 14 deletions

View File

@@ -4,6 +4,11 @@
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
@@ -65,8 +70,26 @@ def hue_in(H, lo, 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):
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"))
@@ -106,9 +129,10 @@ for tf in themes:
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")
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")
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:
@@ -119,7 +143,8 @@ for tf in themes:
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})")
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", [])
@@ -129,7 +154,8 @@ for tf in themes:
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}")
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])
@@ -137,12 +163,31 @@ for tf in themes:
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):
print(f"── {slug}")
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())))