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

@@ -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"],