diff --git a/README.md b/README.md index 5512992..b56d85b 100644 --- a/README.md +++ b/README.md @@ -446,7 +446,10 @@ reload # exec zsh (reload the shell) - **New themed value:** add the key to `theme-state.json` and consume it in the Nix modules. One place — there is no second renderer to keep in sync. - **Importing more old-distro palettes:** - `tools/import-palettes.py themes/`. + `tools/import-palettes.py themes/` (roles are first-class: + when ANSI color0==color8 the tool derives an overlay step; light themes + do not use dark ANSI black as surface — hand-tune after import; do not + bulk-reimport shipped JSON without a hierarchy pass). - **New opt-in feature (convention):** when a feature is off by default and needs the user to set `nomarchy.*` options (e.g. night light, per-device keyboard layouts, monitor layout, power management), ship a **commented** diff --git a/agent/MEMORY.md b/agent/MEMORY.md index e19a00c..53f9d99 100644 --- a/agent/MEMORY.md +++ b/agent/MEMORY.md @@ -86,6 +86,14 @@ iteration would otherwise rediscover. the module on it; a new `class` that only recolors an existing glyph is a regression. Suppressed notification states (DND *and* app-inhibited) all use the bell-off glyph + @muted. +- **Identity themes are not traffic lights** (#69): white, vantablack, + lumon, hackerman, matte-black, miasma — monochrome / mono-hue / earthy + status by design. `audit-theme-design.py` tags their hue/CVD/ANSI-family + findings `[identity]`; do not "fix" them into R/Y/G. +- **Import hierarchy ≠ ANSI** (#70): `import-palettes.py` must not set + surface==overlay when color0==color8; light color0 is often ANSI black + (not a chip). Roles are first-class — never bulk-reimport shipped JSON + without a hierarchy pass. ## Gotchas (cost a debugging session once) - Waybar `layer: top` renders above **even real-fullscreen windows** — the diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6f13a2c..6a336de 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -877,6 +877,13 @@ Design/decision records and a running log of shipped work (items marked cover all three generated surfaces. Status glyph accents (good/warn/bad on base, 2.0–2.7 in some palettes) were left as-is — raising them is palette design, a human call. +- ✓ **Identity audit exemptions + import hierarchy** (#69/#70): + `tools/audit-theme-design.py` tags expected hue/CVD/ANSI-family noise + on white/vantablack/lumon/hackerman/matte-black/miasma as `[identity]` + (do not retune into traffic lights). `tools/import-palettes.py` keeps + surface≠overlay when ANSI color0==color8, derives light chips from base + instead of ANSI black, and documents that roles are first-class — no + bulk re-import of shipped themes without a hierarchy pass. - ✓ **Waybar crash on theme switch left the session bar-less** (Latitude hardware QA, 2026-07-04): exec-once has no supervisor, so any crash orphaned the session until relogin — and the switch path itself was diff --git a/tools/audit-theme-design.py b/tools/audit-theme-design.py index 33903c1..6ea537a 100755 --- a/tools/audit-theme-design.py +++ b/tools/audit-theme-design.py @@ -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()))) diff --git a/tools/import-palettes.py b/tools/import-palettes.py index 63c32a1..1ec4d39 100644 --- a/tools/import-palettes.py +++ b/tools/import-palettes.py @@ -15,6 +15,20 @@ Output, per theme: .json the palette (Nomarchy theme schema) /backgrounds/ wallpapers, copied verbatim /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"],