feat: add ash light/dark omarchy themes derived from harbor

This commit is contained in:
Richard Zink
2026-09-22 11:11:39 +02:00
commit 99cef66d61
117 changed files with 14041 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Generate the Ash Omarchy themes (ash-light / ash-dark) from the Harbor
reference themes in tools/reference/, using the Ash palette defined for
TriliumNext in trilium-themes/ash-{light,dark}.css.
Usage: python3 tools/recolor.py <repo-root>
"""
import math
import re
import sys
from pathlib import Path
ASH_DARK = {
"void": "100f12", "panel": "1c1b1f", "raised": "29282c", "border": "3a383d",
"mid": "54514f", "ash": "6d6a63", "stone": "999187", "bone": "e6dfd1",
"chalk": "f3ede1", "icon": "b0a89a", "ember": "e0914f", "gold": "d6b46a",
"blood": "c15c46", "rot": "b47e99", "moss": "8f9a5c", "steel": "7c9aa1",
"steel_light": "a3bcc1", "violet": "a585ac", "rust": "a86a48",
}
ASH_LIGHT = {
"paper": "e9e3d5", "raised": "f2ecdf", "deep": "dcd5c3", "border": "c9c1ad",
"border2": "b7ae98", "muted": "6f685c", "icon": "5b5449", "grey": "8d8578",
"grey2": "a89e85", "ink": "2a2822", "ink_dark": "141310",
"ember": "a85f1d", "ember_bright": "c56a2b", "gold": "8a6a24",
"blood": "96392a", "blood_bright": "b04331", "blood_dark": "7a2f24",
"rot": "7c4a66", "moss": "5c6631", "steel": "3d6b72", "violet": "6a4c72",
"rust": "7d4a2c",
}
MAP_DARK = {
"1b1b1b": "1c1b1f", "141414": "100f12", "191919": "100f12", "1f1f1f": "100f12",
"323232": "29282c", "383838": "3a383d", "4c4c4c": "54514f",
"6d6d6d": "54514f", "888888": "6d6a63", "929292": "6d6a63", "b6b6b6": "999187",
"87856d": "999187", "817f68": "6d6a63", "9a9880": "999187",
"efebdc": "e6dfd1", "f4f2e8": "f3ede1", "e1ce98": "e6dfd1",
"e75a50": "e0914f", "df7e76": "e0914f", "ff7783": "c15c46", "f44336": "c15c46",
"d83a42": "c15c46", "d83a41": "c15c46", "e58980": "b47e99",
"a99b7a": "d6b46a", "c0af7f": "d6b46a", "e67e22": "e0914f",
"cc954c": "d6b46a", "ca9654": "d6b46a",
"9683a1": "a585ac", "77838a": "7c9aa1", "77868a": "7c9aa1",
"9ba2a6": "a3bcc1", "99a4af": "a3bcc1", "b3b9bc": "b0a89a",
}
MAP_LIGHT = {
"dfe4c4": "e9e3d5", "d3d9b0": "dcd5c3", "c8d4a8": "dcd5c3", "ecebbd": "f2ecdf",
"d8d9b8": "dcd5c3", "bec2a7": "c9c1ad",
"1c2d28": "2a2822", "253631": "141310", "2a2a2a": "2a2822", "383838": "2a2822",
"384f54": "5b5449", "7d8794": "8d8578", "83838b": "8d8578", "82838b": "8d8578",
"858f8b": "8d8578", "acb3b1": "a89e85", "606c68": "6f685c",
"5e81ac": "a85f1d",
"4c6c94": "3d6b72", "476489": "3d6b72", "386593": "3d6b72", "3a6a9a": "3d6b72",
"3c6499": "3d6b72", "316a7b": "3d6b72", "286b75": "3d6b72", "256b7a": "3d6b72",
"3d727d": "3d6b72",
"b14752": "96392a", "a4424c": "7a2f24", "9d2e3a": "7a2f24", "b03743": "96392a",
"d83a42": "b04331", "d83a41": "b04331", "ff0000": "96392a",
"556753": "5c6631", "43a25a": "5c6631", "43a258": "5c6631", "40a258": "5c6631",
"4a6a4a": "5c6631", "466b46": "5c6631",
"dc8164": "c56a2b",
"8a5b81": "6a4c72", "7f5578": "7c4a66", "835665": "7c4a66",
"5a4a7a": "6a4c72", "695998": "6a4c72",
"cc954c": "8a6a24", "ca9654": "8a6a24",
"8f5424": "7d4a2c", "9d4b25": "7d4a2c", "955121": "7d4a2c",
# 3-digit shorthand (vscode symbol icons)
"f0f": "6a4c72",
}
VARIANTS = {
"light": {"ash": ASH_LIGHT, "out": "ash-light",
"map": MAP_LIGHT,
"post": []},
"dark": {"ash": ASH_DARK, "out": "ash-dark",
"map": MAP_DARK,
"post": [("Ashdark", "Ash Dark"), ("ashdark", "ash-dark")]},
}
NAME_REPLACEMENTS = [("HARBOR", "ASH"), ("Harbor", "Ash"), ("harbor", "ash")]
HEX8 = re.compile(r"#([0-9a-fA-F]{6})([0-9a-fA-F]{2})\b")
HEX6 = re.compile(r"#([0-9a-fA-F]{6})\b")
HEX3 = re.compile(r"#([0-9a-fA-F]{3})\b")
RGBA_DEC = re.compile(r"(rgba?\(\s*)(\d{1,3})(\s*,\s*)(\d{1,3})(\s*,\s*)(\d{1,3})(\s*,[^)])")
RGBA_HEX = re.compile(r"(?<![0-9a-fA-F])([0-9a-fA-F]{6})([0-9a-fA-F]{2})(?![0-9a-fA-F])")
def rgb(h):
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
def dist(a, b):
ar, ag, ab = rgb(a)
br, bg, bb = rgb(b)
# weighted distance with luminance bias
return math.sqrt(2 * (ar - br) ** 2 + 4 * (ag - bg) ** 2 + 3 * (ab - bb) ** 2)
class Recolorer:
def __init__(self, mapping, palette, post=()):
self.mapping = mapping
self.post = post
self.targets = sorted(set(mapping.values()))
self.rgb_map = {rgb(k): v for k, v in mapping.items() if len(k) == 6}
self.unmapped = {}
def resolve(self, h):
h = h.lower()
if h in self.mapping:
return self.mapping[h]
src = rgb(h)
if src in self.rgb_map:
return self.rgb_map[src]
# pure greys / white / black overlays: leave untouched
r, g, b = src
if max(src) - min(src) < 24:
return None
nearest = min(self.targets, key=lambda t: dist(h, t))
if dist(h, nearest) > 150:
self.unmapped[h] = self.unmapped.get(h, 0) + 1
return None
return nearest
def fix_case(self, out, ref):
return out.upper() if ref != ref.lower() else out
def text(self, s):
s = HEX8.sub(lambda m: "#" + self.fix_case(self.resolve(m[1]) or m[1], m[1]) + m[2], s)
def h6(m):
r = self.resolve(m[1])
return "#" + self.fix_case(r, m[1]) if r else m[0]
s = HEX6.sub(h6, s)
def h3(m):
r = self.resolve(m[1]) or self.resolve(m[1] * 2)
return "#" + self.fix_case(r, m[1]) if r else m[0]
s = HEX3.sub(h3, s)
def dec(m):
key = f"{int(m[2]):02x}{int(m[4]):02x}{int(m[6]):02x}"
r = self.resolve(key)
if not r:
return m[0]
t = rgb(r)
return f"{m[1]}{t[0]}{m[3]}{t[1]}{m[5]}{t[2]}{m[7]}"
s = RGBA_DEC.sub(dec, s)
# hyprland bare rgba(rrggbbaa) tokens (no # prefix)
s = re.sub(r"(rgba\()([0-9a-fA-F]{6})([0-9a-fA-F]{2})(\))",
lambda m: m[1] + self.fix_case(self.resolve(m[2]) or m[2], m[2]) + m[3] + m[4],
s)
# hyprland bare rgb(rrggbb) tokens (no # prefix)
s = re.sub(r"(rgba?\()([0-9a-fA-F]{6})(\))",
lambda m: m[1] + self.fix_case(self.resolve(m[2]) or m[2], m[2]) + m[3],
s)
for old, new in NAME_REPLACEMENTS:
s = s.replace(old, new)
for old, new in self.post:
s = s.replace(old, new)
return s
def main():
root = Path(sys.argv[1]).resolve()
total_unmapped = {}
for variant, cfg in VARIANTS.items():
ref_dir = root / "tools/reference" / variant
out_dir = root / cfg["out"]
recolorer = Recolorer(cfg["map"], cfg["ash"], cfg.get("post", ()))
for src in sorted(ref_dir.rglob("*")):
if not src.is_file():
continue
rel = src.relative_to(ref_dir)
name = re.sub(r"(?i)harbordark", "ash-dark", str(rel))
name = re.sub(r"(?i)harbor", "ash", name)
dst = out_dir / name
dst.parent.mkdir(parents=True, exist_ok=True)
data = src.read_text(encoding="utf-8")
out = recolorer.text(data)
if rel.name == "icons.theme":
out = "Yaru" if variant == "light" else "Yaru-dark"
dst.write_text(out, encoding="utf-8")
for k, v in recolorer.unmapped.items():
total_unmapped[f"{variant}:{k}"] = v
print(f"{cfg['out']}: generated")
if total_unmapped:
print("left unchanged (no good Ash equivalent):")
for k, v in sorted(total_unmapped.items(), key=lambda x: -x[1]):
print(f" {k} x{v}")
if __name__ == "__main__":
main()