"""File-backed catalog of Web App UI themes.""" from __future__ import annotations import json import logging import re from pathlib import Path from typing import Any, Dict, List, Literal, Optional from pydantic import BaseModel, Field, model_validator logger = logging.getLogger(__name__) ColorScheme = Literal["light", "dark"] class ThemeTokens(BaseModel): """CSS design tokens for the subscription Mini App shell.""" model_config = {"extra": "ignore"} color_scheme: ColorScheme = "dark" style_preset: Optional[str] = None accent: Optional[str] = None bg: Optional[str] = None panel: Optional[str] = None panel_2: Optional[str] = None panel_3: Optional[str] = None border: Optional[str] = None border_strong: Optional[str] = None text: Optional[str] = None muted: Optional[str] = None dim: Optional[str] = None danger: Optional[str] = None blue: Optional[str] = None radius: Optional[str] = None font_sans: Optional[str] = None font_logo: Optional[str] = None font_mono: Optional[str] = None admin_bg: Optional[str] = None admin_surface: Optional[str] = None admin_surface_2: Optional[str] = None admin_elev: Optional[str] = None admin_border: Optional[str] = None admin_border_strong: Optional[str] = None admin_text: Optional[str] = None admin_muted: Optional[str] = None admin_dim: Optional[str] = None class WebappTheme(BaseModel): """Single theme descriptor loaded from WEBAPP_THEMES_DIR//theme.json.""" model_config = {"extra": "ignore"} key: str = Field(min_length=1, max_length=64) names: Dict[str, str] = Field(default_factory=dict) enabled: bool = True default: bool = False use_primary_accent: bool = True use_in_admin: bool = True css_file: Optional[str] = None assets_version: int = 1 tokens: ThemeTokens = Field(default_factory=ThemeTokens) class WebappThemesConfig(BaseModel): """Runtime catalog assembled from individual theme descriptor files.""" model_config = {"extra": "ignore"} default_theme: str = "dark" themes: List[WebappTheme] = Field(default_factory=list) @model_validator(mode="after") def _validate_default_and_keys(self) -> WebappThemesConfig: keys = [t.key for t in self.themes] if len(keys) != len(set(keys)): raise ValueError("duplicate theme keys") if self.themes and self.default_theme not in keys: raise ValueError("default_theme must match a theme key") return self def theme_by_key(self, key: str) -> Optional[WebappTheme]: for theme in self.themes: if theme.key == key: return theme return None def enabled_themes(self) -> List[WebappTheme]: return [theme for theme in self.themes if theme.enabled] DEFAULT_THEME_KEYS = ("dark", "light", "windows95", "ascii") THEME_DESCRIPTOR_FILENAME = "theme.json" DEFAULT_THEMES_SOURCE_DIR = Path(__file__).resolve().parents[1] / "bot" / "app" / "web" / "themes" def _safe_theme_key(value: str) -> Optional[str]: key = str(value or "").strip() if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key): return key return None def _theme_dir_path(theme_dir: str | Path, key: str) -> Path: safe_key = _safe_theme_key(key) if not safe_key: raise ValueError(f"invalid theme key: {key!r}") return Path(theme_dir).expanduser() / safe_key def _theme_file_path(theme_dir: str | Path, key: str) -> Path: return _theme_dir_path(theme_dir, key) / THEME_DESCRIPTOR_FILENAME def default_webapp_theme_css_files() -> Dict[str, str]: """Read default theme CSS from repository files.""" out: Dict[str, str] = {} for key in DEFAULT_THEME_KEYS: source_dir = DEFAULT_THEMES_SOURCE_DIR / key for source_path in sorted(source_dir.rglob("*.css")): rel_path = Path(key) / source_path.relative_to(source_dir) try: content = source_path.read_text(encoding="utf-8") except OSError as exc: logger.warning( "Default webapp theme CSS source file is missing: %s (%s)", source_path, exc, ) continue out[rel_path.as_posix()] = content if content.endswith("\n") else f"{content}\n" return out def default_webapp_theme_asset_file(rel_path: str | Path) -> Optional[tuple[bytes, str]]: """Read a default theme asset from the repository theme folder.""" relative = Path(rel_path) if relative.is_absolute() or len(relative.parts) < 2 or ".." in relative.parts: return None source_path = (DEFAULT_THEMES_SOURCE_DIR / relative).resolve() try: source_path.relative_to(DEFAULT_THEMES_SOURCE_DIR.resolve()) except ValueError: return None try: return source_path.read_bytes(), source_path.suffix.lower() except OSError: return None def default_webapp_theme_descriptors() -> Dict[str, Dict[str, Any]]: """Read default theme descriptors from repository files.""" out: Dict[str, Dict[str, Any]] = {} for key in DEFAULT_THEME_KEYS: source_path = DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME try: raw = json.loads(source_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: logger.warning( "Default webapp theme descriptor is missing or invalid: %s (%s)", source_path, exc, ) continue if not isinstance(raw, dict): continue safe_key = _safe_theme_key(str(raw.get("key") or source_path.parent.name)) if safe_key: raw["key"] = safe_key out[safe_key] = raw return out def _theme_from_descriptor(path: Path, raw: Any) -> Optional[WebappTheme]: if not isinstance(raw, dict): logger.warning("Ignoring theme descriptor %s: expected JSON object", path) return None data = dict(raw) data["key"] = data.get("key") or ( path.parent.name if path.name == THEME_DESCRIPTOR_FILENAME else path.stem ) safe_key = _safe_theme_key(str(data["key"])) if not safe_key: logger.warning("Ignoring theme descriptor %s: invalid theme key %r", path, data["key"]) return None data["key"] = safe_key try: return WebappTheme.model_validate(data) except ValueError as exc: logger.warning("Ignoring theme descriptor %s: %s", path, exc) return None def load_webapp_theme_file(path: str | Path) -> Optional[WebappTheme]: theme_path = Path(path).expanduser() try: raw = json.loads(theme_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: logger.warning("Failed to load webapp theme descriptor from %s: %s", theme_path, exc) return None return _theme_from_descriptor(theme_path, raw) def load_webapp_theme_dir(theme_dir: str | Path) -> List[WebappTheme]: root = Path(theme_dir).expanduser() if not root.exists(): return [] themes_by_key: Dict[str, WebappTheme] = {} for path in sorted(root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}")): if path.parent.name.startswith("_"): continue theme = load_webapp_theme_file(path) if theme is None: continue if theme.key in themes_by_key: logger.warning("Ignoring duplicate webapp theme key %s from %s", theme.key, path) continue themes_by_key[theme.key] = theme return list(themes_by_key.values()) def _config_with_synced_default_flags(config: WebappThemesConfig) -> WebappThemesConfig: data = config.model_dump(mode="json", exclude_none=True) default_theme = str(data.get("default_theme") or "dark") themes = data.get("themes", []) for theme in themes: if isinstance(theme, dict): theme["default"] = theme.get("key") == default_theme def _sort_key(item: tuple[int, Any]) -> tuple[int, int]: idx, theme = item if not isinstance(theme, dict): return (len(THEME_DISPLAY_ORDER), idx) try: priority = THEME_DISPLAY_ORDER.index(str(theme.get("key") or "")) except ValueError: priority = len(THEME_DISPLAY_ORDER) return (priority, idx) data["themes"] = [theme for _, theme in sorted(enumerate(themes), key=_sort_key)] return WebappThemesConfig.model_validate(data) THEME_DISPLAY_ORDER = ("dark", "light") def _theme_sort_key(theme: WebappTheme, index: int) -> tuple[int, int]: try: priority = THEME_DISPLAY_ORDER.index(theme.key) except ValueError: priority = len(THEME_DISPLAY_ORDER) return (priority, index) def _sorted_themes(themes: List[WebappTheme]) -> List[WebappTheme]: return [theme for _, theme in sorted( ((_theme_sort_key(t, i), t) for i, t in enumerate(themes)), key=lambda pair: pair[0], )] def _themes_config_from_list( default_theme: Optional[str], themes: List[WebappTheme], ) -> WebappThemesConfig: keys = {theme.key for theme in themes} descriptor_default = next( (theme.key for theme in themes if theme.default and theme.key not in DEFAULT_THEME_KEYS), None, ) or next((theme.key for theme in themes if theme.default), None) resolved_default = default_theme or descriptor_default or "dark" if themes and resolved_default not in keys: resolved_default = "dark" if "dark" in keys else themes[0].key config = WebappThemesConfig(default_theme=resolved_default, themes=_sorted_themes(themes)) return _config_with_synced_default_flags(config) def _write_webapp_theme_file(path: Path, theme: WebappTheme) -> None: path.parent.mkdir(parents=True, exist_ok=True) data = theme.model_dump(mode="json", exclude_none=True) payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n" tmp_path = path.with_suffix(f"{path.suffix}.tmp") try: tmp_path.write_text(payload, encoding="utf-8") tmp_path.replace(path) except PermissionError: if tmp_path.exists(): try: tmp_path.unlink() except OSError: pass path.write_text(payload, encoding="utf-8") def _copy_default_theme_assets(key: str, target_dir: Path, *, overwrite: bool = False) -> None: source_dir = DEFAULT_THEMES_SOURCE_DIR / key if not source_dir.exists(): return for source_path in sorted(source_dir.rglob("*")): if not source_path.is_file() or source_path.name == THEME_DESCRIPTOR_FILENAME: continue rel_path = source_path.relative_to(source_dir) target_path = target_dir / rel_path if target_path.exists() and not overwrite: continue try: target_path.parent.mkdir(parents=True, exist_ok=True) target_path.write_bytes(source_path.read_bytes()) except OSError as exc: logger.warning("Could not create default webapp theme asset %s: %s", target_path, exc) def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool: style_path = target_dir / "style.css" try: style = style_path.read_text(encoding="utf-8") except OSError: return True if key == "light": return ".theme-key-light.app-shell" not in style if key == "ascii": return ( ".theme-key-ascii" not in style or "ascii-spin" not in style or "ascii-skeleton-scan" not in style or "ascii-boot-type" not in style or "Console-style tables" not in style ) if key != "windows95": return False required_icons = ( "arrow-right.png", "dashboard.png", "megaphone.png", "paintbrush.png", "sliders.png", "sparkles.png", "tag.png", ) return ( "lucide-house" not in style or "lucide-earth" not in style or "lucide-circle-check" not in style or "border-radius: 0 !important" not in style or any(not (target_dir / "icons" / icon).exists() for icon in required_icons) ) def ensure_default_webapp_theme_descriptor_files(theme_dir: str | Path | None) -> None: """Seed source-controlled default theme folders into the mounted data directory.""" if not theme_dir: return root = Path(theme_dir).expanduser() try: root.mkdir(parents=True, exist_ok=True) except OSError as exc: logger.warning("Could not create webapp themes directory at %s: %s", root, exc) return existing_has_default = any(theme.default for theme in load_webapp_theme_dir(root)) existing_by_key = {theme.key: theme for theme in load_webapp_theme_dir(root)} for key, descriptor in default_webapp_theme_descriptors().items(): theme_dir_path = _theme_dir_path(root, key) path = _theme_file_path(root, key) existing = existing_by_key.get(key) source_assets_version = int(descriptor.get("assets_version") or 1) should_sync_assets = ( existing is not None and key in DEFAULT_THEME_KEYS and ( int(existing.assets_version or 0) < source_assets_version or _builtin_theme_assets_need_refresh(key, theme_dir_path) ) ) if not path.exists(): seed_descriptor = dict(descriptor) if existing_has_default: seed_descriptor["default"] = False theme = _theme_from_descriptor(path, seed_descriptor) if theme is not None: try: _write_webapp_theme_file(path, theme) except OSError as exc: logger.warning( "Could not create default webapp theme descriptor %s: %s", path, exc, ) elif should_sync_assets and existing is not None: data = existing.model_dump(mode="json", exclude_none=True) data["assets_version"] = source_assets_version if descriptor.get("css_file"): data["css_file"] = descriptor["css_file"] try: theme = WebappTheme.model_validate(data) _write_webapp_theme_file(path, theme) except (OSError, ValueError) as exc: logger.warning("Could not update default webapp theme descriptor %s: %s", path, exc) _copy_default_theme_assets(key, theme_dir_path, overwrite=should_sync_assets) def write_webapp_theme_dir( theme_dir: str | Path, config: WebappThemesConfig, *, delete_missing: bool = False, ) -> None: """Write one theme.json descriptor per theme into WEBAPP_THEMES_DIR/.""" root = Path(theme_dir).expanduser() root.mkdir(parents=True, exist_ok=True) normalized = _config_with_synced_default_flags(config) keep_paths = set() for theme in normalized.themes: path = _theme_file_path(root, theme.key) _write_webapp_theme_file(path, theme) keep_paths.add(path.resolve()) if not delete_missing: return for path in root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}"): if path.parent.name.startswith("_") or path.resolve() in keep_paths: continue try: path.unlink() if not any(path.parent.iterdir()): path.parent.rmdir() except OSError as exc: logger.warning("Could not delete removed webapp theme descriptor %s: %s", path, exc) def ensure_webapp_core_themes( config: WebappThemesConfig, primary_accent: str ) -> tuple[WebappThemesConfig, bool]: """Keep dark, light and Windows 95 themes available without clobbering custom edits.""" data = config.model_dump(mode="json", exclude_none=True) themes = data.setdefault("themes", []) by_key = {str(theme.get("key")): theme for theme in themes if isinstance(theme, dict)} changed = False for builtin in builtin_webapp_themes_config(primary_accent).themes: builtin_data = builtin.model_dump(mode="json", exclude_none=True) existing = by_key.get(builtin.key) if existing is None: themes.append(builtin_data) by_key[builtin.key] = builtin_data changed = True continue if existing.get("enabled") is False: existing["enabled"] = True changed = True if "use_primary_accent" not in existing: existing["use_primary_accent"] = builtin_data.get("use_primary_accent", True) changed = True if "use_in_admin" not in existing: existing["use_in_admin"] = builtin_data.get("use_in_admin", True) changed = True if int(existing.get("assets_version") or 0) < int(builtin_data.get("assets_version") or 1): existing["assets_version"] = builtin_data.get("assets_version", 1) changed = True if builtin.key in {"light", "windows95", "ascii"} and not existing.get("css_file"): existing["css_file"] = builtin_data.get("css_file") changed = True tokens = existing.setdefault("tokens", {}) builtin_tokens = builtin_data.get("tokens", {}) for token_key in ("color_scheme", "style_preset"): if token_key in builtin_tokens and not tokens.get(token_key): tokens[token_key] = builtin_tokens[token_key] changed = True keys = {str(theme.get("key")) for theme in themes if isinstance(theme, dict)} if themes and data.get("default_theme") not in keys: data["default_theme"] = "dark" if "dark" in keys else str(themes[0].get("key") or "dark") changed = True normalized = _config_with_synced_default_flags(WebappThemesConfig.model_validate(data)) if normalized.model_dump(mode="json", exclude_none=True) != data: changed = True return normalized, changed def builtin_webapp_themes_config(primary_accent: str) -> WebappThemesConfig: """Default catalog backed by repository theme descriptor files.""" accent = (primary_accent or "#00fe7a").strip() or "#00fe7a" themes: List[WebappTheme] = [] descriptors = default_webapp_theme_descriptors() for key in DEFAULT_THEME_KEYS: raw = descriptors.get(key) if not raw: continue theme = _theme_from_descriptor( DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME, raw, ) if theme is None: continue if theme.key == "dark" and not theme.tokens.accent: theme.tokens.accent = accent themes.append(theme) return _themes_config_from_list(None, themes) def apply_webapp_theme_env_overrides( config: WebappThemesConfig, env_default_theme: Optional[str] ) -> WebappThemesConfig: """If WEBAPP_DEFAULT_THEME is set and matches a theme key, override the default theme.""" raw = (env_default_theme or "").strip() if not raw: return config if config.theme_by_key(raw) is None: logger.warning("WEBAPP_DEFAULT_THEME=%r ignored: no such theme in catalog", raw) return config data = config.model_dump(mode="json", exclude_none=True) data["default_theme"] = raw return _config_with_synced_default_flags(WebappThemesConfig.model_validate(data)) def resolved_webapp_themes_catalog( *, theme_dir: str | Path, primary_accent: str, env_default_theme: Optional[str], ) -> WebappThemesConfig: """Load themes from WEBAPP_THEMES_DIR, seeding defaults when possible.""" ensure_default_webapp_theme_descriptor_files(theme_dir) themes = load_webapp_theme_dir(theme_dir) config = _themes_config_from_list(None, themes) config, changed = ensure_webapp_core_themes(config, primary_accent) if changed: try: write_webapp_theme_dir(theme_dir, config, delete_missing=False) except OSError as exc: logger.warning("Could not update webapp theme descriptors in %s: %s", theme_dir, exc) return apply_webapp_theme_env_overrides(config, env_default_theme) def merge_primary_accent_into_theme_tokens( theme: WebappTheme, primary_accent: str, *, only_if_token_missing: bool = True ) -> ThemeTokens: """Fill accent from WEBAPP_PRIMARY_COLOR when theme tokens omit accent.""" base = theme.tokens.model_copy(deep=True) accent = (primary_accent or "").strip() if not accent: return base if only_if_token_missing and base.accent: return base base.accent = accent return base def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]: tokens = ( merge_primary_accent_into_theme_tokens(theme, primary_accent) if theme.use_primary_accent else theme.tokens ) payload: Dict[str, object] = { "key": theme.key, "names": dict(theme.names), "enabled": bool(theme.enabled), "use_primary_accent": bool(theme.use_primary_accent), "use_in_admin": bool(theme.use_in_admin), "tokens": tokens.model_dump(mode="json", exclude_none=True), } if theme.css_file: payload["css_file"] = theme.css_file return payload def public_themes_catalog_payload( config: WebappThemesConfig, primary_accent: str, *, enabled_only: bool = False ) -> Dict[str, object]: themes = [theme for theme in config.themes if not enabled_only or theme.enabled] return { "default_theme": config.default_theme, "themes": [public_theme_payload(theme, primary_accent) for theme in themes], }