refactor: restructure project into muxplex/ subdir with brand integration

- Move coordinator/, frontend/, Caddyfile, pyproject.toml, requirements.txt,
  docs/ into muxplex/ subdir in prep for packaging/sharing
- Add brand assets to frontend/: favicon.ico, pwa-192/512.png,
  apple-touch-icon.png, wordmark-on-dark.svg
- Update app: title → muxplex, header → wordmark SVG, brand color tokens
  in style.css, manifest.json updated with muxplex name and brand icons
- Add design system: assets/branding/tokens.css (101 CSS custom properties),
  tokens.json (127 tokens), DESIGN-SYSTEM.md (856-line spec)
- Add assets/branding/: SVG sources, rendered PNGs (icons, favicons, PWA, OG)
- Add scripts/render-brand-assets.py for reproducible PNG generation
- Add muxplex/README.md
This commit is contained in:
Brian Krabach
2026-03-27 15:06:00 -07:00
commit 8234e2ec05
76 changed files with 17006 additions and 0 deletions
+438
View File
@@ -0,0 +1,438 @@
#!/usr/bin/env python3
"""
muxplex brand asset renderer
Usage: python3 render-brand-assets.py [--force] [--workdir PATH]
Renders all SVG sources to PNG/ICO for web, PWA, favicons, and OG images.
Run from the ./muxplex/ directory or pass --workdir path.
Renderer priority: cairosvg > rsvg-convert > inkscape > imagemagick (convert/magick)
"""
import importlib.util
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
from argparse import ArgumentParser
from pathlib import Path
# ──────────────────────────────────────────────────────────────
# Argument parsing
# ──────────────────────────────────────────────────────────────
parser = ArgumentParser(description="Render muxplex brand assets")
parser.add_argument(
"--force", action="store_true", help="Re-render even if output exists"
)
parser.add_argument("--workdir", default=None, help="Base directory (default: cwd)")
args = parser.parse_args()
WORKDIR = Path(args.workdir).resolve() if args.workdir else Path.cwd()
ASSETS = WORKDIR / "assets" / "branding"
SVG_DIR = ASSETS / "svg"
FORCE = args.force
# Verify we're in the right place
if not SVG_DIR.exists():
sys.exit(
f"[ERROR] Cannot find {SVG_DIR}\n"
"Run from the muxplex/ directory or pass --workdir <path-to-muxplex-dir>"
)
# ──────────────────────────────────────────────────────────────
# Renderer detection
# ──────────────────────────────────────────────────────────────
RENDERER: str | None = None
if importlib.util.find_spec("cairosvg") is not None:
RENDERER = "cairosvg"
print("[renderer] cairosvg detected ✓")
if RENDERER is None and shutil.which("rsvg-convert"):
RENDERER = "rsvg-convert"
print("[renderer] rsvg-convert detected ✓")
if RENDERER is None and shutil.which("inkscape"):
RENDERER = "inkscape"
print("[renderer] inkscape detected ✓")
if RENDERER is None:
for _magick_bin in ("magick", "convert"):
if shutil.which(_magick_bin):
RENDERER = f"imagemagick:{_magick_bin}"
print(f"[renderer] ImageMagick ({_magick_bin}) detected ✓")
break
if RENDERER is None:
sys.exit(
"[ERROR] No SVG renderer found.\n"
"Install one of: cairosvg (pip install cairosvg), librsvg2-bin (rsvg-convert),\n"
" inkscape, or imagemagick"
)
# ──────────────────────────────────────────────────────────────
# Pillow detection
# ──────────────────────────────────────────────────────────────
HAS_PILLOW = importlib.util.find_spec("PIL") is not None
if HAS_PILLOW:
print("[pillow] Pillow detected ✓")
else:
print("[pillow] Pillow not available — ICO and OG composition will be skipped")
# ──────────────────────────────────────────────────────────────
# SVG introspection helpers
# ──────────────────────────────────────────────────────────────
def get_svg_dimensions(svg_path: Path) -> tuple[float | None, float | None]:
"""Return (width, height) floats from the SVG root element."""
tree = ET.parse(svg_path)
root = tree.getroot()
w = root.get("width")
h = root.get("height")
# Fall back to viewBox
if not w or not h:
vb = root.get("viewBox", "")
parts = vb.replace(",", " ").split()
if len(parts) == 4:
w, h = parts[2], parts[3]
if w and h:
# Strip units (px, pt, etc.)
w_f = float("".join(c for c in str(w) if c in "0123456789."))
h_f = float("".join(c for c in str(h) if c in "0123456789."))
return w_f, h_f
return None, None
# ──────────────────────────────────────────────────────────────
# Core render function
# ──────────────────────────────────────────────────────────────
def render_svg(
src: Path,
dst: Path,
width: int | None = None,
height: int | None = None,
) -> bool:
"""
Render src SVG to dst PNG at the given width and/or height.
If only one dimension is given the other is computed from aspect ratio.
Returns True on success.
"""
if dst.exists() and not FORCE:
print(f" [skip] {dst.relative_to(WORKDIR)}")
return True
dst.parent.mkdir(parents=True, exist_ok=True)
# Compute missing dimension from aspect ratio
svg_w, svg_h = get_svg_dimensions(src)
if svg_w and svg_h:
aspect = svg_w / svg_h
if width and not height:
height = round(width / aspect)
elif height and not width:
width = round(height * aspect)
# Final fallback dimensions
final_w: int = width if width is not None else int(svg_w or 64)
final_h: int = height if height is not None else int(svg_h or 64)
try:
if RENDERER == "cairosvg":
import cairosvg # type: ignore[import-untyped]
cairosvg.svg2png(
url=str(src),
write_to=str(dst),
output_width=final_w,
output_height=final_h,
)
elif RENDERER == "rsvg-convert":
subprocess.run(
[
"rsvg-convert",
"-w",
str(final_w),
"-h",
str(final_h),
"-o",
str(dst),
str(src),
],
check=True,
capture_output=True,
)
elif RENDERER == "inkscape":
subprocess.run(
[
"inkscape",
"--export-type=png",
f"--export-width={final_w}",
f"--export-height={final_h}",
f"--export-filename={dst}",
str(src),
],
check=True,
capture_output=True,
)
else:
# ImageMagick — RENDERER is "imagemagick:convert" or "imagemagick:magick"
#
# ImageMagick's built-in SVG renderer (MSVG) handles clip-paths
# reliably only at the SVG's native pixel dimensions. Rendering at
# any other size typically produces a 1-colour (transparent) result.
#
# Strategy:
# 1. Render to a temp PNG at native SVG dimensions via ImageMagick.
# 2. Scale the temp PNG to the requested target size with Pillow
# (LANCZOS — high quality, preserves edges).
# 3. Fall back to a direct ImageMagick resize if Pillow is absent
# (quality may be poor for upscaling).
assert RENDERER is not None # guaranteed by startup check
bin_name = RENDERER.split(":")[1]
native_w, native_h = get_svg_dimensions(src)
if native_w and native_h and HAS_PILLOW:
from PIL import Image
tmp_native = dst.parent / f"_tmp_native_{src.stem}.png"
# Render at native SVG size (no resize flag → ImageMagick uses
# the dimensions declared in the SVG header).
subprocess.run(
[bin_name, "-background", "none", str(src), str(tmp_native)],
check=True,
capture_output=True,
)
# Pillow: open → resize with LANCZOS → save
img = Image.open(tmp_native).convert("RGBA")
img = img.resize((final_w, final_h), Image.Resampling.LANCZOS)
img.save(str(dst))
tmp_native.unlink(missing_ok=True)
else:
# No Pillow — attempt a direct ImageMagick resize.
# Works acceptably for downscaling; upscaling may be imprecise.
subprocess.run(
[
bin_name,
"-background",
"none",
"-resize",
f"{final_w}x{final_h}!",
str(src),
str(dst),
],
check=True,
capture_output=True,
)
print(f" [ok] {dst.relative_to(WORKDIR)} ({final_w}×{final_h})")
return True
except Exception as exc:
print(f" [FAIL] {dst.relative_to(WORKDIR)}{exc}")
return False
# ──────────────────────────────────────────────────────────────
# Generated-file registry (for summary table)
# ──────────────────────────────────────────────────────────────
generated: list[dict[str, str]] = []
def track(path: Path, status: str = "ok") -> None:
size_kb = f"{path.stat().st_size / 1024:.1f}K" if path.exists() else ""
generated.append(
{"path": str(path.relative_to(WORKDIR)), "size": size_kb, "status": status}
)
# ──────────────────────────────────────────────────────────────
# Step 1 — App icons
# ──────────────────────────────────────────────────────────────
print("\n── App icons ─────────────────────────────────────────────")
ICON_SRC = SVG_DIR / "icon" / "muxplex-icon-dark.svg"
ICONS_DIR = ASSETS / "icons"
ICON_SIZES = [16, 22, 24, 32, 48, 64, 128, 192, 256, 512, 1024]
for sz in ICON_SIZES:
dst = ICONS_DIR / f"muxplex-icon-{sz}.png"
ok = render_svg(ICON_SRC, dst, width=sz, height=sz)
if dst.exists():
track(dst, "ok" if ok else "fail")
# ──────────────────────────────────────────────────────────────
# Step 2 — Favicons
# ──────────────────────────────────────────────────────────────
print("\n── Favicons ──────────────────────────────────────────────")
FAV_DIR = ASSETS / "favicons"
for sz in [16, 32, 48]:
dst = FAV_DIR / f"favicon-{sz}.png"
ok = render_svg(ICON_SRC, dst, width=sz, height=sz)
if dst.exists():
track(dst)
# apple-touch-icon 180×180
ati = FAV_DIR / "apple-touch-icon.png"
ok = render_svg(ICON_SRC, ati, width=180, height=180)
if ati.exists():
track(ati)
# favicon.ico (multi-res 16+32+48) — requires Pillow
ico_path = FAV_DIR / "favicon.ico"
if not ico_path.exists() or FORCE:
if HAS_PILLOW:
from PIL import Image
fav_pngs = [FAV_DIR / f"favicon-{s}.png" for s in [16, 32, 48]]
if all(p.exists() for p in fav_pngs):
imgs = [Image.open(p).convert("RGBA") for p in fav_pngs]
imgs[0].save(
str(ico_path),
format="ICO",
sizes=[(s, s) for s in [16, 32, 48]],
append_images=imgs[1:],
)
print(f" [ok] {ico_path.relative_to(WORKDIR)} (multi-res ICO)")
else:
print(" [WARN] favicon PNGs missing — ICO skipped")
else:
print(" [skip] favicon.ico — Pillow not available")
if ico_path.exists():
track(ico_path)
# ──────────────────────────────────────────────────────────────
# Step 3 — PWA icons
# ──────────────────────────────────────────────────────────────
print("\n── PWA icons ─────────────────────────────────────────────")
PWA_DIR = ASSETS / "pwa"
for sz in [192, 512]:
dst = PWA_DIR / f"pwa-{sz}.png"
ok = render_svg(ICON_SRC, dst, width=sz, height=sz)
if dst.exists():
track(dst)
# ──────────────────────────────────────────────────────────────
# Step 4 — Wordmark PNGs
# ──────────────────────────────────────────────────────────────
print("\n── Wordmarks ─────────────────────────────────────────────")
WM_DIR = ASSETS / "wordmark"
for variant in ["dark", "light"]:
src = SVG_DIR / "wordmark" / f"wordmark-on-{variant}.svg"
for h in [32, 64]:
dst = WM_DIR / f"wordmark-on-{variant}-{h}.png"
ok = render_svg(src, dst, height=h)
if dst.exists():
track(dst)
# ──────────────────────────────────────────────────────────────
# Step 5 — Lockup PNGs
# ──────────────────────────────────────────────────────────────
print("\n── Lockups ───────────────────────────────────────────────")
LOCKUP_DIR = ASSETS / "lockup"
for variant in ["dark", "light"]:
src = SVG_DIR / "lockup" / f"lockup-on-{variant}.svg"
for h in [32, 64]:
dst = LOCKUP_DIR / f"lockup-on-{variant}-{h}.png"
ok = render_svg(src, dst, height=h)
if dst.exists():
track(dst)
# ──────────────────────────────────────────────────────────────
# Step 6 — OG images (1200×630)
# ──────────────────────────────────────────────────────────────
print("\n── OG images ─────────────────────────────────────────────")
OG_DIR = ASSETS / "og"
OG_DIR.mkdir(parents=True, exist_ok=True)
OG_CONFIGS: list[dict] = [
{
"variant": "dark",
"bg_hex": "#0D1117",
"bg_rgb": (13, 17, 23),
"lockup_src": SVG_DIR / "lockup" / "lockup-on-dark.svg",
"out": OG_DIR / "og-dark.png",
},
{
"variant": "light",
"bg_hex": "#FFFFFF",
"bg_rgb": (255, 255, 255),
"lockup_src": SVG_DIR / "lockup" / "lockup-on-light.svg",
"out": OG_DIR / "og-light.png",
},
]
for cfg in OG_CONFIGS:
out_path: Path = cfg["out"]
if out_path.exists() and not FORCE:
print(f" [skip] {out_path.relative_to(WORKDIR)}")
track(out_path, "skip")
continue
if HAS_PILLOW:
from PIL import Image
# Render lockup at height=200 into a temp file
tmp_lockup = OG_DIR / f"_tmp_lockup_{cfg['variant']}.png"
ok = render_svg(cfg["lockup_src"], tmp_lockup, height=200)
if not ok or not tmp_lockup.exists():
print(f" [FAIL] OG {cfg['variant']} — lockup render failed")
continue
bg = Image.new("RGB", (1200, 630), color=cfg["bg_rgb"])
lockup_img = Image.open(tmp_lockup).convert("RGBA")
# Centre the lockup on the canvas
x = (1200 - lockup_img.width) // 2
y = (630 - lockup_img.height) // 2
bg.paste(lockup_img, (x, y), lockup_img)
bg.save(str(out_path))
tmp_lockup.unlink(missing_ok=True)
print(
f" [ok] {out_path.relative_to(WORKDIR)} (1200×630, bg={cfg['bg_hex']})"
)
else:
# Fallback: render lockup at height=200, no canvas composition
ok = render_svg(cfg["lockup_src"], out_path, height=200)
if ok:
print(
f" [NOTE] {out_path.relative_to(WORKDIR)}"
"Pillow unavailable; rendered lockup only (no 1200×630 canvas).\n"
" Install Pillow and re-run to get proper OG images."
)
if out_path.exists():
track(out_path)
# ──────────────────────────────────────────────────────────────
# Summary table
# ──────────────────────────────────────────────────────────────
print("\n" + "" * 70)
print(f" {'FILE':<52} {'SIZE':>6} STATUS")
print("" * 70)
for item in generated:
status_icon = (
"" if item["status"] == "ok" else ("·" if item["status"] == "skip" else "")
)
print(f" {item['path']:<52} {item['size']:>6} {status_icon}")
print("" * 70)
print(f" Total: {len(generated)} files\n")