Files
gamut/tools/generate_example.py
James MaesandClaude Opus 5.5 3b0ff08df5
lint / lint (push) Successful in 7s
build / lint (push) Successful in 7s
lint / ansible-lint (push) Skipped
tools: sort the generator imports (ruff I001)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:35:27 -06:00

172 lines
7.0 KiB
Python

"""Generate the worked-example GAMUT theme 'Ember Dusk' with computed metrics."""
import itertools
import json
from coloraide import Color
# ---------- APCA (SAPC-4g) ----------
def _y(hexcol):
c = Color(hexcol).convert('srgb')
r, g, b = [max(ch, 0.0) ** 2.4 for ch in c.coords()]
y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b
if y < 0.022:
y += (0.022 - y) ** 1.414
return y
def apca(txt_hex, bg_hex):
ytxt, ybg = _y(txt_hex), _y(bg_hex)
if ybg > ytxt: # dark text on light bg
s = (ybg ** 0.56 - ytxt ** 0.57) * 1.14
lc = 0.0 if s < 0.1 else s - 0.027
else: # light text on dark bg
s = (ybg ** 0.65 - ytxt ** 0.62) * 1.14
lc = 0.0 if s > -0.1 else s + 0.027
return abs(lc * 100)
# ---------- deltaE-OK ----------
def de_ok(h1, h2):
a = Color(h1).convert('oklab').coords()
b = Color(h2).convert('oklab').coords()
return sum((x - y) ** 2 for x, y in zip(a, b)) ** 0.5
# ---------- Machado 2009 CVD simulation (severity 1.0), linear sRGB ----------
M = {
'protanopia': [[0.152286,1.052583,-0.204868],[0.114503,0.786281,0.099216],[-0.003882,-0.048116,1.051998]],
'deuteranopia':[[0.367322,0.860646,-0.227968],[0.280085,0.672501,0.047413],[-0.011820,0.042940,0.968881]],
'tritanopia': [[1.255528,-0.076749,-0.178779],[-0.078411,0.930809,0.147602],[0.004733,0.691367,0.303900]],
}
def cvd(hexcol, kind):
c = Color(hexcol).convert('srgb-linear').coords()
m = M[kind]
out = [min(1.0, max(0.0, sum(m[i][j] * c[j] for j in range(3)))) for i in range(3)]
return Color('srgb-linear', out).convert('srgb').to_string(hex=True, fit='oklch-chroma')
def oklch_to_hex(L, C, H):
return Color('oklch', [L, C, H]).convert('srgb').to_string(hex=True, fit='oklch-chroma')
def col(L, C, H):
return {"oklch": [round(L, 3), round(C, 3), round(H, 1)], "hex": oklch_to_hex(L, C, H)}
# ---------- Theme definition (parameters -> roles) ----------
HUE_BG = 285.0
core = {
"bg": col(0.20, 0.020, HUE_BG),
"bgAlt": col(0.25, 0.022, HUE_BG),
"surface": col(0.31, 0.025, HUE_BG),
"muted": col(0.68, 0.030, HUE_BG),
"fgAlt": col(0.76, 0.025, HUE_BG),
"fg": col(0.86, 0.020, HUE_BG),
"fgBright": col(0.92, 0.015, HUE_BG),
"bgInverse": col(0.96, 0.010, HUE_BG),
# accents: lightness staggered (optimizer output) so CVD-confusable hues
# stay separated by lightness; all reach APCA Lc >= 60 vs bg
"accent0": col(0.795, 0.140, 25), # red - errors/variables
"accent1": col(0.840, 0.130, 55), # orange - constants/numbers (the 'ember')
"accent2": col(0.880, 0.120, 95), # yellow - classes/search
"accent3": col(0.830, 0.110, 145), # green - strings
"accent4": col(0.830, 0.090, 200), # cyan - regex/escapes
"accent5": col(0.780, 0.110, 255), # blue - functions
"accent6": col(0.865, 0.120, 320), # magenta- keywords
"accent7": col(0.620, 0.070, 55), # brown - deprecated/tags
}
ACCENTS = [f"accent{i}" for i in range(7)] # accent7 exempt from accent gates
bg = core["bg"]["hex"]
# ---------- Metrics ----------
fg_bg = apca(core["fg"]["hex"], bg)
muted_bg = apca(core["muted"]["hex"], bg)
acc_bg = {a: apca(core[a]["hex"], bg) for a in ACCENTS}
pairs = list(itertools.combinations(ACCENTS, 2))
sep = {f"{a}/{b}": de_ok(core[a]["hex"], core[b]["hex"]) for a, b in pairs}
min_sep = min(sep.values())
cvd_res = {}
for kind in M:
sim = {a: cvd(core[a]["hex"], kind) for a in ACCENTS}
d = min(de_ok(sim[a], sim[b]) for a, b in pairs)
cvd_res[kind] = {"minAccentPair": round(d, 3), "safe": d >= 0.04}
Ls = [core[a]["oklch"][0] for a in ACCENTS]
Cs = [core[a]["oklch"][1] for a in ACCENTS]
Hs = sorted(core[a]["oklch"][2] for a in ACCENTS)
gaps = [Hs[i+1] - Hs[i] for i in range(len(Hs)-1)] + [360 - Hs[-1] + Hs[0]]
hue_spread = 360 - max(gaps)
ramp = ["bg","bgAlt","surface","muted","fgAlt","fg","fgBright","bgInverse"]
mono = all(core[ramp[i]]["oklch"][0] < core[ramp[i+1]]["oklch"][0] for i in range(7))
badges = []
if fg_bg >= 75 and muted_bg >= 45 and all(v >= 60 for v in acc_bg.values()): badges.append("aa-readable")
if fg_bg >= 90 and all(v >= 75 for v in acc_bg.values()): badges.append("aaa-readable")
if min_sep >= 0.04: badges.append("distinct-accents")
if "distinct-accents" in badges and all(r["safe"] for r in cvd_res.values()): badges.append("cvd-safe")
if max(Ls)-min(Ls) <= 0.10 and max(Cs)-min(Cs) <= 0.06: badges.append("coherent")
badges.append("conventional-hues")
report = {
"gates": {
"ramp_monotonic": mono,
"fg_bg_apca": round(fg_bg,1), "fg_gate(>=75)": fg_bg >= 75,
"muted_bg_apca": round(muted_bg,1), "muted_gate(>=45)": muted_bg >= 45,
"accents_bg_apca": {k: round(v,1) for k,v in acc_bg.items()},
"accent_gate(>=60)": all(v >= 60 for v in acc_bg.values()),
"min_accent_separation_deltaEOK": round(min_sep,3), "sep_gate(>=0.04)": min_sep >= 0.04,
"cvd": cvd_res,
},
"badges": badges,
}
print(json.dumps(report, indent=2))
# ---------- Assemble theme file ----------
theme = {
"$schema": "https://gamut.style/schema/v0.1/theme.schema.json",
"format": "gamut/0.1",
"id": "gamut/ember-dusk",
"version": "1.0.0",
"name": "Ember Dusk",
"mode": "dark",
"author": {"name": "GAMUT"},
"license": "CC0-1.0",
"tags": ["warm", "dark", "ember", "violet-night", "cozy"],
"description": "A violet-night dark theme warmed by ember-orange accents.",
"core": core,
"ui": {
"borderFocus": core["accent5"],
"error": core["accent0"], "warning": core["accent2"],
"success": core["accent3"], "info": core["accent5"],
"link": core["accent5"], "findMatchBg": core["accent2"],
},
"syntax": {
"keyword": core["accent6"], "function": core["accent5"],
"string": core["accent3"], "number": core["accent1"],
"type": core["accent2"],
"comment": {**core["muted"], "style": ["italic"]},
},
"ansi": {
"black": core["bgAlt"], "red": core["accent0"], "green": core["accent3"],
"yellow": core["accent2"], "blue": core["accent5"], "magenta": core["accent6"],
"cyan": core["accent4"], "white": core["fg"],
"brightBlack": core["muted"], "brightRed": col(0.84, 0.150, 25),
"brightGreen": col(0.87, 0.120, 145), "brightYellow": col(0.92, 0.130, 95),
"brightBlue": col(0.83, 0.120, 255), "brightMagenta": col(0.90, 0.130, 320),
"brightCyan": col(0.88, 0.100, 200), "brightWhite": core["fgBright"],
},
"metrics": {
"computedWith": "gamut-tools/0.1.0-prototype",
"contrast": {"method": "APCA-4g", "fgBg": round(fg_bg,1),
"minAccentBg": round(min(acc_bg.values()),1)},
"separation": {"method": "deltaE-OK", "minAccentPair": round(min_sep,3)},
"cvd": {"method": "machado-2009", **cvd_res},
"coherence": {"accentLightnessRange": round(max(Ls)-min(Ls),3),
"accentChromaRange": round(max(Cs)-min(Cs),3),
"hueSpreadDegrees": round(hue_spread,0)},
"badges": badges,
},
}
out = "/sessions/sweet-gallant-cori/mnt/Theme Color Master/example.ember-dusk.theme.json"
with open(out, "w") as f:
json.dump(theme, f, indent=2)
print("written:", out)