Skip to main content

max / makenotwork

9.8 KB · 259 lines History Blame Raw
1 //! Tier 0 creator theming.
2 //!
3 //! The platform ships a set of built-in palettes (the themes `makeover`
4 //! embeds, the same set the desktop apps consume). A creator may
5 //! pick one for their public profile and for each project; items inherit their
6 //! parent project's choice. `None` everywhere means the platform default,
7 //! [`DEFAULT_THEME_ID`].
8 //!
9 //! Themes are embedded at compile time so production needs no themes directory
10 //! on disk. Each theme is resolved to its intent tokens and rendered once via
11 //! [`makeover::intent_css_vars`] and cached; page handlers inject the
12 //! rendered string into the page `<head>`, where it overrides the default
13 //! intent `:root` from `static/style.css` (the brand aliases derive from there,
14 //! so the whole page re-themes). This is the single TOML -> CSS mapping shared
15 //! with GoingsOn and audiofiles.
16
17 use std::collections::BTreeMap;
18 use std::sync::LazyLock;
19
20 use makeover::{
21 ThemeMeta, ThemeSelection, embedded_themes, intent_css_vars, parse_theme_str, resolve,
22 };
23
24 /// The platform default theme id, the stock parchment look. Reproduces the
25 /// historical `:root` exactly, so an unset (`None`) choice renders unchanged.
26 pub const DEFAULT_THEME_ID: &str = "makenotwork";
27
28 /// A built-in theme: its metadata (for the picker) and its pre-rendered
29 /// intent-layer CSS (for `<head>` injection).
30 pub struct ThemeEntry {
31 pub meta: ThemeMeta,
32 /// `:root { ... }` block of resolved intent tokens, ready to inline.
33 pub css: String,
34 }
35
36 /// Registry of built-in themes keyed by id, sorted by id for a stable picker.
37 static REGISTRY: LazyLock<BTreeMap<String, ThemeEntry>> = LazyLock::new(|| {
38 let mut map = BTreeMap::new();
39 for (id, content) in embedded_themes() {
40 match parse_theme_str(id, content, false) {
41 Ok(theme) => {
42 let tokens = resolve(&theme);
43 let css = intent_css_vars(&tokens);
44 map.insert(
45 id.to_string(),
46 ThemeEntry {
47 meta: tokens.meta,
48 css,
49 },
50 );
51 }
52 Err(e) => {
53 // A malformed bundled theme is a build-content bug, not a
54 // runtime input, log and skip rather than abort startup.
55 tracing::error!(theme = id, error = %e, "skipping unparseable bundled theme");
56 }
57 }
58 }
59 map
60 });
61
62 /// Whether `id` names a known built-in theme. Use to validate a creator's
63 /// chosen id before persisting it.
64 pub fn is_valid_theme(id: &str) -> bool {
65 REGISTRY.contains_key(id)
66 }
67
68 /// The rendered primitive-layer CSS for a creator's chosen theme.
69 ///
70 /// Falls back to the default theme when the choice is `None` or names a theme
71 /// that is not in the registry (a bundled theme removed after a creator picked
72 /// it). Returns `""` only if even the default is missing, which would be
73 /// a build-content bug; the page then renders with the stylesheet's own `:root`.
74 pub fn theme_css(id: Option<&str>) -> &'static str {
75 let chosen = id
76 .filter(|i| REGISTRY.contains_key(*i))
77 .unwrap_or(DEFAULT_THEME_ID);
78 REGISTRY
79 .get(chosen)
80 .or_else(|| REGISTRY.get(DEFAULT_THEME_ID))
81 .map_or("", |e| e.css.as_str())
82 }
83
84 /// All built-in themes, sorted by display name, for rendering a theme picker.
85 /// The currently-selected id (or the default) is the caller's concern.
86 pub fn list_themes() -> Vec<&'static ThemeMeta> {
87 let mut themes: Vec<&ThemeMeta> = REGISTRY.values().map(|e| &e.meta).collect();
88 themes.sort_by(|a, b| a.name.cmp(&b.name));
89 themes
90 }
91
92 /// Validate and normalize a submitted theme id.
93 ///
94 /// Trims, treats absent/empty as "clear to the platform default" (`Ok(None)`),
95 /// accepts a known built-in (`Ok(Some(id))`), and rejects any other non-empty
96 /// value (`Err(the_offending_id)`). Callers map the `Err` to a validation error.
97 pub fn normalize_theme_id(raw: Option<&str>) -> Result<Option<String>, String> {
98 match raw.map(str::trim) {
99 None | Some("") => Ok(None),
100 Some(id) if is_valid_theme(id) => Ok(Some(id.to_string())),
101 Some(id) => Err(id.to_string()),
102 }
103 }
104
105 /// Validate and normalize a submitted console-theme selection.
106 ///
107 /// Unlike [`normalize_theme_id`] this yields a `makeover::ThemeSelection`
108 /// string rather than an optional id, because "follow the terminal" is a choice
109 /// the row holds and not an absence: a creator who picks it after pinning a
110 /// theme is saying something, and clearing the column back to `NULL` would lose
111 /// the difference between that and never having chosen.
112 ///
113 /// Absent or empty input is [`makeover::FOLLOW`], matching
114 /// `ThemeSelection::parse`. Any other value must name a bundled theme, since an
115 /// id the CLI's embedded set does not carry cannot be honoured there either.
116 pub fn normalize_console_theme(raw: Option<&str>) -> Result<String, String> {
117 match ThemeSelection::parse(raw) {
118 ThemeSelection::Follow => Ok(makeover::FOLLOW.to_string()),
119 ThemeSelection::Fixed(id) if is_valid_theme(&id) => Ok(id),
120 ThemeSelection::Fixed(id) => Err(id),
121 }
122 }
123
124 /// One `<option>` for a theme `<select>`: id, display name, and whether it is
125 /// the creator's current choice.
126 #[derive(Debug, Clone)]
127 pub struct ThemeOption {
128 pub id: String,
129 pub name: String,
130 pub selected: bool,
131 }
132
133 /// Build the picker options, marking the creator's current choice selected.
134 ///
135 /// An unset or no-longer-valid `selected` resolves to [`DEFAULT_THEME_ID`], so
136 /// exactly one option is always marked.
137 pub fn theme_options(selected: Option<&str>) -> Vec<ThemeOption> {
138 let current = selected
139 .filter(|i| is_valid_theme(i))
140 .unwrap_or(DEFAULT_THEME_ID);
141 list_themes()
142 .into_iter()
143 .map(|m| ThemeOption {
144 id: m.id.clone(),
145 name: m.name.clone(),
146 selected: m.id == current,
147 })
148 .collect()
149 }
150
151 /// Build the console picker options: "follow the terminal" first, then every
152 /// bundled theme.
153 ///
154 /// Follow leads because it is what an unconfigured console does and what the
155 /// house convention makes the default. `stored` is the raw column value, so an
156 /// unset row and a stored `"system"` both land on it, as does a pinned theme
157 /// that has since left the bundled set.
158 pub fn console_theme_options(stored: Option<&str>) -> Vec<ThemeOption> {
159 let current = match ThemeSelection::parse(stored) {
160 ThemeSelection::Fixed(id) if is_valid_theme(&id) => id,
161 _ => makeover::FOLLOW.to_string(),
162 };
163 let mut options = vec![ThemeOption {
164 id: makeover::FOLLOW.to_string(),
165 name: "Follow the terminal".to_string(),
166 selected: current == makeover::FOLLOW,
167 }];
168 options.extend(list_themes().into_iter().map(|m| ThemeOption {
169 id: m.id.clone(),
170 name: m.name.clone(),
171 selected: m.id == current,
172 }));
173 options
174 }
175
176 #[cfg(test)]
177 mod tests {
178 use super::*;
179
180 #[test]
181 fn default_theme_is_bundled() {
182 assert!(
183 is_valid_theme(DEFAULT_THEME_ID),
184 "makenotwork.toml must be bundled"
185 );
186 }
187
188 #[test]
189 fn default_css_carries_intent_tokens() {
190 let css = theme_css(None);
191 // Intent vocabulary the page's brand aliases derive from.
192 // The two surface values are the re-ramped parchment (makeover 2.4.1):
193 // the original sat 2.8 L* between page and raised, which no bevel can
194 // read. Pinning them here is what makes the style.css :root default and
195 // the injected block provably the same theme rather than two drifting
196 // copies of it.
197 assert!(css.contains("--surface-page: #ddd6c9;"));
198 assert!(css.contains("--content: #3d3530;"));
199 assert!(css.contains("--action: #6c5ce7;"));
200 assert!(css.contains("--border: #b3ab97;"));
201 // Derived states + the scrim are present too.
202 assert!(css.contains("--action-hover: "));
203 assert!(css.contains("--overlay: rgba("));
204 }
205
206 #[test]
207 fn unknown_id_falls_back_to_default() {
208 assert_eq!(theme_css(Some("no-such-theme")), theme_css(None));
209 assert_eq!(theme_css(Some("../etc/passwd")), theme_css(None));
210 }
211
212 #[test]
213 fn known_alternate_theme_differs_from_default() {
214 // Sanity: a real alternate palette renders different CSS.
215 assert!(is_valid_theme("nord"));
216 assert_ne!(theme_css(Some("nord")), theme_css(None));
217 }
218
219 #[test]
220 fn console_selection_normalizes_to_a_theme_selection_string() {
221 assert_eq!(normalize_console_theme(None).unwrap(), makeover::FOLLOW);
222 assert_eq!(normalize_console_theme(Some("")).unwrap(), makeover::FOLLOW);
223 assert_eq!(
224 normalize_console_theme(Some("system")).unwrap(),
225 makeover::FOLLOW
226 );
227 assert_eq!(normalize_console_theme(Some(" nord ")).unwrap(), "nord");
228 assert_eq!(
229 normalize_console_theme(Some("no-such-theme")),
230 Err("no-such-theme".to_string())
231 );
232 }
233
234 #[test]
235 fn console_picker_leads_with_follow_and_marks_one_option() {
236 for stored in [None, Some(""), Some("system"), Some("no-such-theme")] {
237 let options = console_theme_options(stored);
238 assert_eq!(options[0].id, makeover::FOLLOW);
239 assert!(
240 options[0].selected,
241 "unset/unknown must fall back to follow, got {stored:?}"
242 );
243 assert_eq!(options.iter().filter(|o| o.selected).count(), 1);
244 }
245
246 let options = console_theme_options(Some("nord"));
247 assert!(!options[0].selected);
248 assert_eq!(options.iter().filter(|o| o.selected).count(), 1);
249 assert!(options.iter().any(|o| o.id == "nord" && o.selected));
250 }
251
252 #[test]
253 fn picker_list_is_nonempty_and_includes_default() {
254 let themes = list_themes();
255 assert!(themes.len() > 1);
256 assert!(themes.iter().any(|m| m.id == DEFAULT_THEME_ID));
257 }
258 }
259