#!/usr/bin/env python3
"""Regenerates src/palette/generated.rs from the live theme sources.
The neiam palette lives in dms as daisyUI themes; the Casabeza palette was
recovered from the cbzauth APK after the source loss. Neither is retyped by
hand here — run this after either moves and the tool follows.
tools/regen_palettes.py [--source PATH ...] [--check]
--check exits non-zero if the generated file is stale, for CI.
Several sites carry the same daisyUI themes, and one of them (waxx) also
defines `blueprint`, so more than one stylesheet is read and the results are
merged by theme name. The shared themes are identical between sources; where
they ever diverge, the earlier source in the list wins and the difference is
reported rather than silently resolved.
"""
import argparse
import math
import pathlib
import re
import sys
HERE = pathlib.Path(__file__).resolve().parent.parent
PROJECTS = pathlib.Path.home() / "Development/projects"
# Ordered: the first stylesheet to define a theme wins. dms leads because it is
# the palette's home; waxx follows because it is where `blueprint` lives.
DEFAULT_SOURCES = [
PROJECTS / "dms/assets/f/css/style.css",
PROJECTS / "waxx/assets/css/app.css",
PROJECTS / "gitgud/assets/css/app.css",
]
OUT = HERE / "src/palette/generated.rs"
# Semantic roles worth exposing as icon colours. daisyUI defines more, but
# these are the ones that read as a palette rather than as UI plumbing.
ROLES = [
"base-100", "base-200", "base-300", "base-content",
"primary", "primary-content", "secondary", "secondary-content",
"accent", "accent-content", "neutral", "neutral-content",
"info", "success", "warning", "error",
]
def oklch_to_srgb(l, c, h_deg):
"""OKLCH -> sRGB u8 triple. Mirrors the conversion the Android clients use."""
h = math.radians(h_deg)
a, b = math.cos(h) * c, math.sin(h) * c
l_ = l + 0.3963377774 * a + 0.2158037573 * b
m_ = l - 0.1055613458 * a - 0.0638541728 * b
s_ = l - 0.0894841775 * a - 1.2914855480 * b
L, M, S = l_ ** 3, m_ ** 3, s_ ** 3
r = 4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S
g = -1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S
bl = -0.0041960863 * L - 0.7034186147 * M + 1.7076147010 * S
def enc(x):
x = max(0.0, min(1.0, x))
s = 12.92 * x if x <= 0.0031308 else 1.055 * (x ** (1 / 2.4)) - 0.055
return max(0, min(255, round(s * 255)))
return enc(r), enc(g), enc(bl)
def parse_css_color(raw):
raw = raw.strip().rstrip(";").strip()
m = re.fullmatch(r"oklch\(\s*([\d.]+)%?\s+([\d.]+)\s+([\d.]+)\s*\)", raw)
if m:
l, c, h = float(m[1]), float(m[2]), float(m[3])
if "%" in raw.split()[0]:
l /= 100.0
elif l > 1.0:
l /= 100.0
return oklch_to_srgb(l, c, h)
m = re.fullmatch(r"#([0-9a-fA-F]{6})", raw)
if m:
v = int(m[1], 16)
return (v >> 16) & 255, (v >> 8) & 255, v & 255
m = re.fullmatch(r"#([0-9a-fA-F]{3})", raw)
if m:
return tuple(int(ch * 2, 16) for ch in m[1])
return None
def parse_dms(path):
"""Pull each @plugin "daisyui/theme" block out of the dms stylesheet."""
css = path.read_text(encoding="utf-8", errors="replace")
themes = []
for block in re.finditer(r"\{([^{}]*?name:\s*\"[^\"]+\"[^{}]*?)\}", css, re.S):
body = block[1]
name = re.search(r'name:\s*"([^"]+)"', body)[1]
scheme = re.search(r'color-scheme:\s*"?([a-z]+)"?', body)
colors = {}
for role in ROLES:
m = re.search(rf"--color-{re.escape(role)}:\s*([^;]+);", body)
if m:
rgb = parse_css_color(m[1])
if rgb:
colors[role] = rgb
if len(colors) >= 8:
themes.append({
"name": name,
"light": bool(scheme and scheme[1] == "light"),
"colors": colors,
})
return themes
# Recovered from the cbzauth APK (io.gmp.cbzauth ui/theme/AppTheme.kt) after
# the source loss. Exact hex as compiled into the shipped app.
CASABEZA = {
"name": "Casabeza",
"light": False,
"colors": {
"base-100": (0x08, 0x1E, 0x20), # BG_DARK
"base-200": (0x0D, 0x2A, 0x2C), # BG_DARK_ALT
"base-content": (0xEE, 0xF9, 0xFA), # TEXT_INV
"primary": (0x00, 0xC9, 0xD4), # ACCENT
"accent": (0xAA, 0xD4, 0x00), # LOGO_GREEN
"neutral": (0x7A, 0x96, 0x98), # TEXT_MUTED
},
}
CASABEZA_LIGHT = {
"name": "Casabeza Light",
"light": True,
"colors": {
"base-100": (0xF6, 0xFB, 0xFB), # BG_LIGHT
"base-200": (0xE9, 0xF5, 0xF6), # BG_LIGHT_ALT
"base-content": (0x1A, 0x1A, 0x1A), # TEXT_DARK
"primary": (0x00, 0x5A, 0x62), # PRIMARY_TEAL
"accent": (0xC8, 0xF0, 0x17), # LOGO_GREEN_LIGHT
"neutral": (0x3D, 0x5F, 0x61), # TEXT_MUTED_DARK
},
}
def render(themes):
out = [
"// @generated by tools/regen_palettes.py — do not edit by hand.",
"//",
"// neiam themes are read from the dms stylesheet (daisyUI, OKLCH);",
"// the Casabeza entries were recovered from the cbzauth APK.",
"",
"use super::{NamedColor, Theme};",
"",
"// rustfmt would explode each NamedColor across four lines, which makes a",
"// colour table far harder to scan — and CI enforces `cargo fmt --check`,",
"// so without this a regenerate would turn the build red.",
"#[rustfmt::skip]",
"pub const THEMES: &[Theme] = &[",
]
for t in themes:
out.append(" Theme {")
out.append(f' name: "{t["name"]}",')
out.append(f' light: {"true" if t["light"] else "false"},')
out.append(" colors: &[")
for role, (r, g, b) in sorted(t["colors"].items()):
out.append(
f' NamedColor {{ role: "{role}", rgb: [0x{r:02X}, 0x{g:02X}, 0x{b:02X}] }},'
)
out.append(" ],")
out.append(" },")
out.append("];")
out.append("")
return "\n".join(out)
def main():
ap = argparse.ArgumentParser()
ap.add_argument(
"--source",
type=pathlib.Path,
action="append",
dest="sources",
help="a daisyUI stylesheet to read; repeatable, first wins on conflict",
)
ap.add_argument("--check", action="store_true")
args = ap.parse_args()
sources = args.sources or DEFAULT_SOURCES
themes = [CASABEZA, CASABEZA_LIGHT]
seen = {t["name"] for t in themes}
for src in sources:
if not src.exists():
print(f"skipping missing source {src}", file=sys.stderr)
continue
added, dupes, conflicts = [], [], []
for t in parse_dms(src):
if t["name"] in seen:
previous = next(x for x in themes if x["name"] == t["name"])
if previous["colors"] != t["colors"]:
conflicts.append(t["name"])
else:
dupes.append(t["name"])
continue
themes.append(t)
seen.add(t["name"])
added.append(t["name"])
note = f"{src}: +{len(added)}"
if added:
note += f" ({', '.join(added)})"
if dupes:
note += f", {len(dupes)} already defined"
if conflicts:
note += f", DIFFERS from earlier source: {', '.join(conflicts)}"
print(note, file=sys.stderr)
if len(themes) == 2:
print("no stylesheets found; emitting Casabeza only", file=sys.stderr)
text = render(themes)
OUT.parent.mkdir(parents=True, exist_ok=True)
if args.check:
current = OUT.read_text() if OUT.exists() else ""
if current != text:
print("generated.rs is stale; run tools/regen_palettes.py", file=sys.stderr)
return 1
return 0
OUT.write_text(text)
print(f"wrote {OUT} ({len(themes)} themes)", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
neiam
/icon-studio
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
8.0 KiB · text
History
14f2f6e