PSA/admin-theme-generator.py
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

348 lines
14 KiB
Python

#!/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()