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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
348 lines
14 KiB
Python
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()
|