#!/usr/bin/env python3 """ Generate admin panel theme CSS overrides for AlgaPSA. Reads /opt/alga-psa/admin-theme.json and outputs CSS to /var/www/alga-admin-theme.css. The CSS overrides --color-primary-* and --color-secondary-* variables used throughout the AlgaPSA admin dashboard. Usage: python3 /opt/alga-psa/admin-theme-generator.py Config format (/opt/alga-psa/admin-theme.json): { "primaryColor": "#0066CC", "secondaryColor": "#00AA88" } Leave either field empty or omit it to keep the default AlgaPSA color for that channel. """ import json import math import os import sys CONFIG_PATH = "/opt/alga-psa/admin-theme.json" OUTPUT_PATH = "/var/www/alga-admin-theme.css" def hex_to_rgb(hex_color): """Convert hex color to (r, g, b) tuple.""" hex_color = hex_color.lstrip("#") if len(hex_color) != 6: return None try: return ( int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16), ) except ValueError: return None def generate_shades(rgb): """Generate 10 shades (50-900) matching AlgaPSA's generateColorShades algorithm.""" r, g, b = rgb shades = {} # Lighter shades (50-400) — interpolate toward white shades[50] = ( min(255, round(r + (255 - r) * 0.95)), min(255, round(g + (255 - g) * 0.95)), min(255, round(b + (255 - b) * 0.95)), ) shades[100] = ( min(255, round(r + (255 - r) * 0.90)), min(255, round(g + (255 - g) * 0.90)), min(255, round(b + (255 - b) * 0.90)), ) shades[200] = ( min(255, round(r + (255 - r) * 0.75)), min(255, round(g + (255 - g) * 0.75)), min(255, round(b + (255 - b) * 0.75)), ) shades[300] = ( min(255, round(r + (255 - r) * 0.60)), min(255, round(g + (255 - g) * 0.60)), min(255, round(b + (255 - b) * 0.60)), ) shades[400] = ( min(255, round(r + (255 - r) * 0.30)), min(255, round(g + (255 - g) * 0.30)), min(255, round(b + (255 - b) * 0.30)), ) # Base color (500) shades[500] = (r, g, b) # Darker shades (600-900) — multiply toward black shades[600] = ( max(0, round(r * 0.85)), max(0, round(g * 0.85)), max(0, round(b * 0.85)), ) shades[700] = ( max(0, round(r * 0.70)), max(0, round(g * 0.70)), max(0, round(b * 0.70)), ) shades[800] = ( max(0, round(r * 0.50)), max(0, round(g * 0.50)), max(0, round(b * 0.50)), ) shades[900] = ( max(0, round(r * 0.30)), max(0, round(g * 0.30)), max(0, round(b * 0.30)), ) return shades def invert_shades(shades): """Invert shade mapping for dark mode (50<->900, etc.), matching AlgaPSA logic.""" return { 50: shades[900], 100: shades[800], 200: shades[700], 300: shades[600], 400: shades[400], 500: shades[500], 600: shades[300], 700: shades[200], 800: shades[100], 900: shades[50], } def rgb_str(rgb_tuple): """Format RGB tuple as 'R G B' string for CSS var.""" return f"{rgb_tuple[0]} {rgb_tuple[1]} {rgb_tuple[2]}" def palette_vars(name, shades): """Generate CSS custom property lines for a color palette.""" lines = [] for i in [50, 100, 200, 300, 400, 500, 600, 700, 800, 900]: lines.append(f" --color-{name}-{i}: {rgb_str(shades[i])} !important;") return "\n".join(lines) def generate_overrides(name): """Generate the Tailwind/utility class overrides for a color channel.""" if name == "primary": return f""" /* Switch/toggle */ button[role="switch"][data-state="checked"] {{ background-color: rgb(var(--color-primary-500)) !important; }} /* Button variant classes using CSS variables */ .bg-\\[rgb\\(var\\(--color-primary-500\\)\\)\\] {{ background-color: rgb(var(--color-primary-500)) !important; }} .bg-\\[rgb\\(var\\(--color-primary-600\\)\\)\\] {{ background-color: rgb(var(--color-primary-600)) !important; }} .hover\\:bg-\\[rgb\\(var\\(--color-primary-600\\)\\)\\]:hover {{ background-color: rgb(var(--color-primary-600)) !important; }} .bg-\\[rgb\\(var\\(--color-primary-100\\)\\)\\] {{ background-color: rgb(var(--color-primary-100)) !important; }} .bg-\\[rgb\\(var\\(--color-primary-200\\)\\)\\] {{ background-color: rgb(var(--color-primary-200)) !important; }} .hover\\:bg-\\[rgb\\(var\\(--color-primary-200\\)\\)\\]:hover {{ background-color: rgb(var(--color-primary-200)) !important; }} .bg-\\[rgb\\(var\\(--color-primary-50\\)\\)\\] {{ background-color: rgb(var(--color-primary-50)) !important; }} .hover\\:bg-\\[rgb\\(var\\(--color-primary-50\\)\\)\\]:hover {{ background-color: rgb(var(--color-primary-50)) !important; }} .text-\\[rgb\\(var\\(--color-primary-500\\)\\)\\] {{ color: rgb(var(--color-primary-500)) !important; }} .text-\\[rgb\\(var\\(--color-primary-700\\)\\)\\] {{ color: rgb(var(--color-primary-700)) !important; }} .hover\\:text-\\[rgb\\(var\\(--color-primary-700\\)\\)\\]:hover {{ color: rgb(var(--color-primary-700)) !important; }} .border-\\[rgb\\(var\\(--color-primary-500\\)\\)\\] {{ border-color: rgb(var(--color-primary-500)) !important; }} /* Nav link hover text */ a[class*="hover\\:text-\\[rgb\\(var\\(--color-primary"]:hover {{ color: rgb(var(--color-primary-500)) !important; }} /* Purple/indigo Tailwind classes mapped to primary */ .bg-purple-600, .bg-purple-500, .bg-indigo-600, .bg-indigo-500 {{ background-color: rgb(var(--color-primary-500)) !important; }} .bg-purple-100, .bg-indigo-100 {{ background-color: rgb(var(--color-primary-100)) !important; }} .text-purple-600, .text-purple-500, .text-indigo-600, .text-indigo-500 {{ color: rgb(var(--color-primary-500)) !important; }} .border-purple-600, .border-purple-500, .border-indigo-600, .border-indigo-500 {{ border-color: rgb(var(--color-primary-500)) !important; }} .hover\\:bg-purple-700:hover, .hover\\:bg-purple-600:hover, .hover\\:bg-indigo-700:hover, .hover\\:bg-indigo-600:hover {{ background-color: rgb(var(--color-primary-600)) !important; }} .hover\\:text-purple-700:hover, .hover\\:text-purple-600:hover, .hover\\:text-indigo-700:hover, .hover\\:text-indigo-600:hover {{ color: rgb(var(--color-primary-600)) !important; }} .focus\\:ring-purple-500:focus, .focus\\:ring-indigo-500:focus {{ --tw-ring-color: rgb(var(--color-primary-500)) !important; }} .focus\\:border-purple-500:focus, .focus\\:border-indigo-500:focus {{ border-color: rgb(var(--color-primary-500)) !important; }} /* Focus ring default */ *:focus-visible {{ --tw-ring-color: rgb(var(--color-primary-500)) !important; }} /* Inputs */ input:focus, textarea:focus, select:focus {{ border-color: rgb(var(--color-primary-500)) !important; --tw-ring-color: rgb(var(--color-primary-500)) !important; }} input[type="checkbox"]:checked, input[type="radio"]:checked {{ background-color: rgb(var(--color-primary-500)) !important; border-color: rgb(var(--color-primary-500)) !important; }} input[type="checkbox"]:focus, input[type="radio"]:focus {{ --tw-ring-color: rgb(var(--color-primary-500)) !important; border-color: rgb(var(--color-primary-500)) !important; }} /* Border-primary helper */ .border-primary {{ border-color: rgb(var(--color-primary-500)) !important; }} """ else: # secondary return f""" /* Secondary color classes */ .bg-\\[rgb\\(var\\(--color-secondary-500\\)\\)\\] {{ background-color: rgb(var(--color-secondary-500)) !important; }} .bg-\\[rgb\\(var\\(--color-secondary-600\\)\\)\\] {{ background-color: rgb(var(--color-secondary-600)) !important; }} .hover\\:bg-\\[rgb\\(var\\(--color-secondary-600\\)\\)\\]:hover {{ background-color: rgb(var(--color-secondary-600)) !important; }} .bg-\\[rgb\\(var\\(--color-secondary-50\\)\\)\\] {{ background-color: rgb(var(--color-secondary-50)) !important; }} .bg-\\[rgb\\(var\\(--color-secondary-100\\)\\)\\] {{ background-color: rgb(var(--color-secondary-100)) !important; }} .bg-\\[rgb\\(var\\(--color-secondary-200\\)\\)\\] {{ background-color: rgb(var(--color-secondary-200)) !important; }} .bg-\\[rgb\\(var\\(--color-secondary-300\\)\\)\\] {{ background-color: rgb(var(--color-secondary-300)) !important; }} .bg-\\[rgb\\(var\\(--color-secondary-400\\)\\)\\] {{ background-color: rgb(var(--color-secondary-400)) !important; }} .text-\\[rgb\\(var\\(--color-secondary-500\\)\\)\\] {{ color: rgb(var(--color-secondary-500)) !important; }} .text-\\[rgb\\(var\\(--color-secondary-700\\)\\)\\] {{ color: rgb(var(--color-secondary-700)) !important; }} .border-\\[rgb\\(var\\(--color-secondary-400\\)\\)\\] {{ border-color: rgb(var(--color-secondary-400)) !important; }} /* Accent classes mapped to secondary */ .bg-\\[rgb\\(var\\(--color-accent-500\\)\\)\\] {{ background-color: rgb(var(--color-secondary-500)) !important; }} .bg-\\[rgb\\(var\\(--color-accent-50\\)\\)\\] {{ background-color: rgb(var(--color-secondary-50)) !important; }} .text-\\[rgb\\(var\\(--color-accent-500\\)\\)\\] {{ color: rgb(var(--color-secondary-500)) !important; }} .text-\\[rgb\\(var\\(--color-accent-700\\)\\)\\] {{ color: rgb(var(--color-secondary-700)) !important; }} .border-\\[rgb\\(var\\(--color-accent-500\\)\\)\\] {{ border-color: rgb(var(--color-secondary-500)) !important; }} /* Blue Tailwind classes mapped to secondary */ .text-blue-600, .text-blue-500 {{ color: rgb(var(--color-secondary-600)) !important; }} .text-blue-700 {{ color: rgb(var(--color-secondary-700)) !important; }} .text-blue-800 {{ color: rgb(var(--color-secondary-800)) !important; }} .hover\\:text-blue-600:hover, .hover\\:text-blue-500:hover {{ color: rgb(var(--color-secondary-600)) !important; }} .hover\\:text-blue-700:hover {{ color: rgb(var(--color-secondary-700)) !important; }} .hover\\:text-blue-800:hover {{ color: rgb(var(--color-secondary-800)) !important; }} .border-blue-600, .border-blue-500 {{ border-color: rgb(var(--color-secondary-600)) !important; }} .bg-blue-600, .bg-blue-500 {{ background-color: rgb(var(--color-secondary-600)) !important; }} .bg-blue-50 {{ background-color: rgb(var(--color-secondary-50)) !important; }} .bg-blue-100 {{ background-color: rgb(var(--color-secondary-100)) !important; }} .bg-blue-200 {{ background-color: rgb(var(--color-secondary-200)) !important; }} /* Tab active state */ [data-state="active"] {{ color: rgb(var(--color-secondary-600)) !important; border-color: rgb(var(--color-secondary-600)) !important; }} .data-\\[state\\=active\\]\\:text-blue-600[data-state="active"] {{ color: rgb(var(--color-secondary-600)) !important; }} .data-\\[state\\=active\\]\\:border-blue-600[data-state="active"] {{ border-color: rgb(var(--color-secondary-600)) !important; }} """ def main(): # Load config if not os.path.exists(CONFIG_PATH): print(f"No config found at {CONFIG_PATH}, generating empty CSS.") css = "/* No admin theme configured */\n" with open(OUTPUT_PATH, "w") as f: f.write(css) return with open(CONFIG_PATH) as f: config = json.load(f) primary_hex = config.get("primaryColor", "").strip() secondary_hex = config.get("secondaryColor", "").strip() has_primary = bool(primary_hex) has_secondary = bool(secondary_hex) if not has_primary and not has_secondary: print("No colors configured, generating empty CSS.") css = "/* No admin theme colors configured */\n" with open(OUTPUT_PATH, "w") as f: f.write(css) return # Generate shades primary_shades = None secondary_shades = None if has_primary: rgb = hex_to_rgb(primary_hex) if not rgb: print(f"Invalid primary color: {primary_hex}", file=sys.stderr) sys.exit(1) primary_shades = generate_shades(rgb) if has_secondary: rgb = hex_to_rgb(secondary_hex) if not rgb: print(f"Invalid secondary color: {secondary_hex}", file=sys.stderr) sys.exit(1) secondary_shades = generate_shades(rgb) # Build light mode body root_parts = [] if primary_shades: root_parts.append(palette_vars("primary", primary_shades)) if secondary_shades: root_parts.append(palette_vars("secondary", secondary_shades)) root_body = "\n".join(root_parts) # Build dark mode body (inverted shades) dark_parts = [] if primary_shades: dark_parts.append(palette_vars("primary", invert_shades(primary_shades))) if secondary_shades: dark_parts.append(palette_vars("secondary", invert_shades(secondary_shades))) dark_body = "\n".join(dark_parts) # Build overrides overrides = "" if primary_shades: overrides += generate_overrides("primary") if secondary_shades: overrides += generate_overrides("secondary") css = f"""/* Admin Panel Theme Override — generated by admin-theme-generator.py */ /* Do not edit manually. Edit {CONFIG_PATH} and re-run the generator. */ :root {{ {root_body} }} html.dark {{ {dark_body} }} {overrides} """ with open(OUTPUT_PATH, "w") as f: f.write(css) print(f"Generated {len(css)} bytes of CSS to {OUTPUT_PATH}") if has_primary: print(f" Primary: {primary_hex}") if has_secondary: print(f" Secondary: {secondary_hex}") if __name__ == "__main__": main()