#!/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 [--dms PATH] [--check]
--check exits non-zero if the generated file is stale, for CI.
"""
import argparse
import math
import pathlib
import re
import sys
HERE = pathlib.Path(__file__).resolve().parent.parent
DEFAULT_DMS = pathlib.Path.home() / "Development/projects/dms/assets/f/css/style.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};",
"",
f"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("--dms", type=pathlib.Path, default=DEFAULT_DMS)
ap.add_argument("--check", action="store_true")
args = ap.parse_args()
themes = [CASABEZA, CASABEZA_LIGHT]
if args.dms.exists():
found = parse_dms(args.dms)
themes += found
print(f"dms: {len(found)} themes from {args.dms}", file=sys.stderr)
else:
print(f"dms stylesheet not found at {args.dms}; "
f"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.
6.1 KiB · text
History
e745ef6