diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index c1c5b59..befb5d1 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -49,14 +49,61 @@ Bernardo has offered image-expert models for designed graphics (wallpapers, logos, glyphs, preview/branding art): an agent slice that needs artwork must STOP and ask ("say the word") instead of shipping programmer-art. -Slices: (a) the audit — render every theme (VM themed-screenshot -recipe or real captures), review against the rules above, append a -per-surface punch list to this item; (b) palette-level fixes + the -extended contrast/harmony checker; (c) per-surface polish slices; +Slices: (a) ✓ the audit (2026-07-05, iteration #31 — findings below); +(b) palette-level fixes + the extended contrast/harmony checker; +(c) per-surface polish slices; (d) the whole-swap themes brought to the same bar. Visual work is V3-bound by nature — screenshots are the review artifact, hardware confirms. +**Audit findings (slice a — `tools/audit-theme-design.py` over all 21 +palettes + visual pass over previews; identity-aware curation):** +- **P1 · invisible-secondary-text palettes:** summer-day and + flexoki-light have `subtext == base` (contrast 1.0 — literally + invisible) and `text == surface`. Items 25/27 fixed the *consumers* + (swaync/bar stopped using them); the palettes are still traps — any + new surface consuming subtext/surface re-trips. Fix the palettes: + real mid-tone subtext, raised surface. +- **P1 · inverted bg stacks:** miasma's surface is #000000 under a + much lighter base; kanagawa's surface (L .14) sits below base + (L .24). "Raised" surfaces render sunken/black. Retune. +- **P2 · muted floor:** muted-on-base < 2.0 on gruvbox (1.27), + everforest (1.55), lumon (1.68), nord (1.69), white (1.82), latte + (1.91), retro-82 (1.93) — dimmed workspaces/inactive items barely + render (visible on the lumon preview). Adopt ≥ 2.0 and retune. +- **P2 · warn barely reads:** warn-on-base ≈ 2.0–2.3 on rose-pine, + summer-day, latte, flexoki-light — this is the bar's 25%-battery + color. Floor 2.5 (or see the color-only rule below). +- **P2 · text-on-surface < 4.5:** ristretto 3.88, latte 4.39 (menus + and chips draw text on surface). +- **P3 · accentAlt-on-base < 3:** latte 2.34, summer-day 2.83. +- **Design rule (systemic, beats palette surgery): status is never + color-only.** good/warn collapse under protanopia on ~half the + themes and good/bad under deuteranopia (59 pair-findings) — that's + inherent to red/green/yellow, not fixable per-palette. Battery and + recording already carry glyph changes; sweep the remaining + color-only indicators (updates count, notification dot, VPN shield + states) for shape/glyph redundancy. +- **Identity exemptions, made explicit:** vantablack + white + (deliberately hueless statuses), lumon (all-blue), hackerman + (matrix green statuses), matte-black/retro-82 (off-family hues) are + identity, not defects — slice (b)'s checker gets a per-theme + exemption list instead of silently lowered floors. +- **ANSI / greeter notes:** latte, rose-pine and white ship + `ansi[0]` LIGHTER than `ansi[15]` — decide the light-theme ANSI + convention (terminals conventionally keep black dark) before the + greeter/tty theming ships to them. tokyo-night/summer-night + `ansi[7]-on-ansi[0]` ≈ 2.8–3.0 = the greeter's muted text; fine as + muted, watch on hardware. +- **Visual pass (480×270 previews — too small for type/spacing; the + deep per-surface pass needs 1080p captures):** the generated bar's + right cluster runs ~9 modules with uniform spacing — no grouping + rhythm (consider micro-gaps between tray/net · stats · toggles · + battery/notify groups); latte's preview shows a DARK terminal on + the light desktop — verify ghostty consumes the latte palette (or + the preview is stale); summer-day's island-pill whole-swap bar and + lumon's globe identity are strong — preserve through any polish. + ### 14. Automated lock bumps — confirm the first real run Slices b+c shipped 2026-07-05 (`.gitea/workflows/bump.yml`): weekly schedule (Mon 05:17 UTC) + `workflow_dispatch` as the security diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 67ff795..2573f0c 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -17,6 +17,30 @@ Template: --- +## 2026-07-05 — theme design audit (iteration #31, item 28 slice a) +- **Task:** BACKLOG NEXT#28 slice (a) — the design-theory audit + (human-requested; Bernardo asked to run it now). +- **Did:** tools/audit-theme-design.py (new, report-only): OKLCH + lightness architecture, extended WCAG pairs, status-hue families, + accent harmony, Machado protan/deutan simulation, ANSI slot + semantics — over all 21 palettes; plus a visual pass over 4 + representative preview.png captures. Findings curated + identity-aware into a P1–P3 punch list appended to item 28: 2 + invisible-subtext palettes (summer-day, flexoki-light), 2 inverted + bg stacks (miasma, kanagawa), 7 muted-floor misses, warn-barely- + reads ×4, a systemic "status is never color-only" rule (CVD + collapse is inherent, glyph redundancy is the fix), explicit + identity exemptions, light-theme ANSI convention decision, bar + right-cluster grouping rhythm, latte dark-terminal verify. +- **Verified:** V0 (py_compile; report tool, not wired into checks). + Analysis is palette-math + thumbnail previews; per-surface + spacing/type audit needs 1080p captures (slice c prep). +- **Pending:** slice (b) — palette retunes + checker extension w/ + exemption list; needs Bernardo's eye on the P1 retunes (visual + taste calls). Artwork needs none so far — no image-model ask yet. +- **Next suggestion:** slice (b) P1s (summer-day/flexoki subtext+ + surface, miasma/kanagawa stacks) — small hex edits, big payoff. + ## 2026-07-05 — greeter theming from the JSON (iteration #30, item 16) - **Task:** BACKLOG NEXT#16 — tuigreet themed from theme-state.json at system rebuild (the Plymouth model). Item deleted. diff --git a/tools/audit-theme-design.py b/tools/audit-theme-design.py new file mode 100755 index 0000000..33903c1 --- /dev/null +++ b/tools/audit-theme-design.py @@ -0,0 +1,148 @@ +#!/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. +""" +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"] + +findings = {} +def note(slug, cat, msg): + 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") + elif not ok: + note(slug, "hue", f"{role} hue {h:.0f}° outside {fam} family") + 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})") + + # 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}") + 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") +cats = {} +for slug in sorted(findings): + print(f"── {slug}") + for cat, msg in findings[slug]: + cats[cat] = cats.get(cat, 0) + 1 + print(f" [{cat}] {msg}") +clean = [t.stem for t in themes if t.stem not in findings] +print(f"\nclean: {', '.join(clean) if clean else '(none)'}") +print("totals:", dict(sorted(cats.items())))