#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:
@@ -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())))
|
||||
|
||||
@@ -15,6 +15,20 @@ Output, per theme:
|
||||
<slug>.json the palette (Nomarchy theme schema)
|
||||
<slug>/backgrounds/ wallpapers, copied verbatim
|
||||
<slug>/btop.theme copied from apps/ when present
|
||||
|
||||
Hierarchy vs ANSI (#70):
|
||||
Nomarchy roles (surface/overlay/mantle/…) are first-class and may
|
||||
diverge from ansi[0]/ansi[8]. When color0==color8 (gruvbox, everforest,
|
||||
many light themes' black==bright-black), do NOT collapse surface and
|
||||
overlay — derive a raised step so the bg stack has hierarchy.
|
||||
|
||||
Light themes: ANSI color0 is almost always "black" (dark), not a UI
|
||||
chip. Using it as surface paints sludge on a light canvas — detect
|
||||
that trap and derive surface/overlay from base instead.
|
||||
|
||||
After import, hand-tune roles if needed. Do not bulk-reimport shipped
|
||||
themes/*.json without a hierarchy pass — this tool is for *future*
|
||||
imports / new palettes.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -25,33 +39,109 @@ from pathlib import Path
|
||||
|
||||
|
||||
def darken(hex_color: str, factor: float = 0.85) -> str:
|
||||
"""Scale a #rrggbb color toward black (used to derive 'mantle')."""
|
||||
"""Scale a #rrggbb color toward black (used to derive 'mantle' / light chips)."""
|
||||
h = hex_color.lstrip("#")
|
||||
r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
|
||||
return "#{:02x}{:02x}{:02x}".format(*(round(v * factor) for v in (r, g, b)))
|
||||
|
||||
|
||||
def lighten(hex_color: str, amount: float = 0.14) -> str:
|
||||
"""Move each channel toward 255 by `amount` (0..1) — raised dark-mode chip."""
|
||||
h = hex_color.lstrip("#")
|
||||
r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
|
||||
return "#{:02x}{:02x}{:02x}".format(
|
||||
*(max(0, min(255, round(v + (255 - v) * amount))) for v in (r, g, b))
|
||||
)
|
||||
|
||||
|
||||
def _norm(hex_color: str) -> str:
|
||||
return hex_color.strip().lower()
|
||||
|
||||
|
||||
def _luma(hex_color: str) -> float:
|
||||
"""Relative luminance (sRGB), for light-theme ANSI-black detection."""
|
||||
h = hex_color.lstrip("#")
|
||||
r, g, b = (int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4))
|
||||
|
||||
def lin(c: float) -> float:
|
||||
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||
|
||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
||||
|
||||
|
||||
def hierarchy_roles(base: str, color0: str, color8: str, mode: str) -> dict:
|
||||
"""Map ANSI → surface/overlay/mantle without collapsing when 0==8.
|
||||
|
||||
Roles stay independent of the ANSI table: even when color0==color8 in
|
||||
the source palette, surface and overlay must differ so chips/menus
|
||||
can step up the stack.
|
||||
"""
|
||||
# mantle: step "under" the canvas. Light bases use a gentler darken so
|
||||
# cream/off-white does not become muddy grey sludge.
|
||||
mantle = darken(base, 0.85 if mode == "dark" else 0.92)
|
||||
|
||||
surface, overlay = color0, color8
|
||||
|
||||
if mode == "light":
|
||||
# Light-theme trap: color0/color8 are ANSI black / bright-black
|
||||
# (dark), not raised UI chips. Derive chips off base instead.
|
||||
base_L = _luma(base)
|
||||
if _luma(surface) < base_L * 0.5:
|
||||
surface = darken(base, 0.94)
|
||||
if _luma(overlay) < base_L * 0.5:
|
||||
overlay = darken(base, 0.88)
|
||||
|
||||
# When ANSI 0==8 (or the light-theme derive left them equal), force a
|
||||
# hierarchy step: dark mode lightens overlay; light mode darkens it.
|
||||
if _norm(surface) == _norm(overlay):
|
||||
if mode == "dark":
|
||||
overlay = lighten(surface, 0.14)
|
||||
else:
|
||||
overlay = darken(surface, 0.88)
|
||||
print(f" note: color0==color8 — derived overlay hierarchy ({surface} → {overlay})")
|
||||
|
||||
return {"mantle": mantle, "surface": surface, "overlay": overlay}
|
||||
|
||||
|
||||
def convert(theme_dir: Path) -> dict:
|
||||
toml = tomllib.loads((theme_dir / "colors.toml").read_text())
|
||||
slug = theme_dir.name
|
||||
ansi = [toml[f"color{i}"] for i in range(16)]
|
||||
mode = "light" if (theme_dir / "light.mode").exists() else "dark"
|
||||
base = toml["background"]
|
||||
roles = hierarchy_roles(base, toml["color0"], toml["color8"], mode)
|
||||
|
||||
# muted: prefer color8 when it is a mid grey distinct from surface;
|
||||
# otherwise derive so dim labels still read (roles ≠ ANSI when 0==8).
|
||||
muted = toml["color8"]
|
||||
if _norm(muted) == _norm(roles["surface"]) or (
|
||||
mode == "light" and _luma(muted) < _luma(base) * 0.5
|
||||
):
|
||||
if mode == "dark":
|
||||
muted = darken(toml["foreground"], 0.55)
|
||||
else:
|
||||
# mid grey between text and base
|
||||
th, bh = toml["foreground"].lstrip("#"), base.lstrip("#")
|
||||
muted = "#{:02x}{:02x}{:02x}".format(
|
||||
*((int(th[i:i + 2], 16) + int(bh[i:i + 2], 16)) // 2 for i in (0, 2, 4))
|
||||
)
|
||||
|
||||
return {
|
||||
"version": 1,
|
||||
"name": slug.replace("-", " ").title(),
|
||||
"slug": slug,
|
||||
"mode": "light" if (theme_dir / "light.mode").exists() else "dark",
|
||||
"mode": mode,
|
||||
# Empty = auto: nomarchy-theme-sync falls back to the first file in
|
||||
# the theme's backgrounds/ directory.
|
||||
"wallpaper": "",
|
||||
"colors": {
|
||||
"base": toml["background"],
|
||||
"mantle": darken(toml["background"]),
|
||||
"surface": toml["color0"],
|
||||
"overlay": toml["color8"],
|
||||
"base": base,
|
||||
"mantle": roles["mantle"],
|
||||
"surface": roles["surface"],
|
||||
"overlay": roles["overlay"],
|
||||
"text": toml["foreground"],
|
||||
"subtext": toml["color7"],
|
||||
"muted": toml["color8"],
|
||||
"muted": muted,
|
||||
"accent": toml["accent"],
|
||||
"accentAlt": toml["color5"],
|
||||
"good": toml["color2"],
|
||||
|
||||
Reference in New Issue
Block a user