-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy paththemes.py
More file actions
168 lines (133 loc) · 5.66 KB
/
Copy paththemes.py
File metadata and controls
168 lines (133 loc) · 5.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
"""Theme loader + CSS generator.
Reads `themes.yml` (copied from `themes.example.yml` on first run) into
a structured form, validates that every theme defines the full set of
required palette keys, and emits a CSS `[data-theme="<id>"]` block per
theme so the existing frontend keeps working.
Hot-reload: routes/system.py exposes `/api/themes/reload` which calls
`load()` again, allowing palette tweaks without a server restart.
"""
from __future__ import annotations
import logging
import shutil
import threading
import yaml
import config
log = logging.getLogger("clientctl.core.themes")
THEMES_FILE = config.ROOT / "themes.yml"
THEMES_EXAMPLE = config.ROOT / "themes.example.yml"
# Every theme MUST define each of these. Tightens the contract so a typo
# in a fork-edited themes.yml fails loud instead of producing a
# half-rendered UI with white-on-white text somewhere.
REQUIRED_KEYS = {
"bg", "surface", "surface-2", "border",
"text", "muted", "accent",
"error", "success", "warn",
"thumb-track", "thumb-bg",
"inner-glow", "shadow",
"toast-bg", "toast-border",
}
# Cache + lock so reloads stay race-free
_LOCK = threading.Lock()
_STATE: dict = {"themes": {}, "default": "dark", "css": ""}
def _validate(theme_id: str, theme: dict) -> tuple[bool, list[str]]:
"""True iff this theme block is well-formed. Returns missing keys."""
palette = theme.get("palette") or {}
if not isinstance(palette, dict):
return False, ["palette must be a mapping"]
missing = sorted(REQUIRED_KEYS - palette.keys())
return (not missing), missing
def _to_css(themes: dict[str, dict], default_id: str) -> str:
"""Serialise the theme dict into CSS — one block per theme.
The 'default' theme is also written under :root and [data-theme="dark"]
when its id is "dark", to preserve the legacy lookup path.
"""
chunks: list[str] = ["/* Auto-generated by core/themes.py — do not edit by hand. */"]
# :root carries the default palette so unset data-theme still works
default = themes.get(default_id) or next(iter(themes.values()), None)
if default:
chunks.append(_block(":root", default))
for theme_id, theme in themes.items():
chunks.append(_block(f'[data-theme="{theme_id}"]', theme))
return "\n\n".join(chunks)
def _block(selector: str, theme: dict) -> str:
palette = theme["palette"]
lines = [f"{selector} {{"]
for key in sorted(palette.keys()):
lines.append(f" --{key}: {palette[key]};")
if theme.get("color-scheme"):
lines.append(f" color-scheme: {theme['color-scheme']};")
lines.append("}")
return "\n".join(lines)
def load() -> dict:
"""(Re-)load themes.yml and rebuild the CSS string. Returns the state."""
if not THEMES_FILE.exists() and THEMES_EXAMPLE.exists():
shutil.copy(THEMES_EXAMPLE, THEMES_FILE)
log.info("themes.yml created from themes.example.yml")
if not THEMES_FILE.exists():
log.warning("themes.yml missing — falling back to empty theme set")
with _LOCK:
_STATE["themes"] = {}
_STATE["default"] = "dark"
_STATE["css"] = ""
return dict(_STATE)
try:
data = yaml.safe_load(THEMES_FILE.read_text()) or {}
except yaml.YAMLError as e:
log.error("themes.yml is not valid YAML: %s", e)
return dict(_STATE)
raw_themes = data.get("themes") or {}
if not isinstance(raw_themes, dict):
log.error("themes.yml: 'themes' must be a mapping, got %s",
type(raw_themes).__name__)
return dict(_STATE)
valid: dict[str, dict] = {}
for theme_id, theme in raw_themes.items():
if not isinstance(theme, dict):
log.warning("themes.yml: skipping '%s' (not a mapping)", theme_id)
continue
ok, missing = _validate(theme_id, theme)
if not ok:
log.warning("themes.yml: skipping '%s' (missing keys: %s)",
theme_id, missing)
continue
valid[theme_id] = theme
# Enforce the configured cap. Keeps the picker scannable and stops a
# forked themes.yml from quietly bloating the /themes.css payload.
# `CLIENTCTL_THEMES_LIMIT=0` opts out entirely.
limit = getattr(config, "THEMES_LIMIT", 8)
if limit and len(valid) > limit:
dropped = list(valid.keys())[limit:]
valid = dict(list(valid.items())[:limit])
log.warning(
"themes.yml: %d themes defined but limit is %d — ignoring %s. "
"Set CLIENTCTL_THEMES_LIMIT=0 to disable the cap.",
len(dropped) + limit, limit, dropped,
)
default_id = data.get("default") or "dark"
if default_id not in valid and valid:
default_id = next(iter(valid))
css = _to_css(valid, default_id) if valid else ""
with _LOCK:
_STATE["themes"] = valid
_STATE["default"] = default_id
_STATE["css"] = css
log.info("themes loaded: %d valid theme(s), default=%s",
len(valid), default_id)
return dict(_STATE)
def get_state() -> dict:
"""Read-only snapshot of the current theme state."""
with _LOCK:
return {
"themes": list(_STATE["themes"].keys()),
"default": _STATE["default"],
"labels": {tid: t.get("label", tid)
for tid, t in _STATE["themes"].items()},
"accents": {tid: t.get("accent", "")
for tid, t in _STATE["themes"].items()},
"previews": {tid: t.get("palette", {})
for tid, t in _STATE["themes"].items()},
}
def get_css() -> str:
"""Generated CSS — served at /themes.css."""
with _LOCK:
return _STATE["css"]