Skip to main content

max / makenotwork

6.6 KB · 180 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::{ThemeMeta, embedded_themes, intent_css_vars, parse_theme_str, resolve};
21
22 /// The platform default theme id, the stock parchment look. Reproduces the
23 /// historical `:root` exactly, so an unset (`None`) choice renders unchanged.
24 pub const DEFAULT_THEME_ID: &str = "makenotwork";
25
26 /// A built-in theme: its metadata (for the picker) and its pre-rendered
27 /// intent-layer CSS (for `<head>` injection).
28 pub struct ThemeEntry {
29 pub meta: ThemeMeta,
30 /// `:root { ... }` block of resolved intent tokens, ready to inline.
31 pub css: String,
32 }
33
34 /// Registry of built-in themes keyed by id, sorted by id for a stable picker.
35 static REGISTRY: LazyLock<BTreeMap<String, ThemeEntry>> = LazyLock::new(|| {
36 let mut map = BTreeMap::new();
37 for (id, content) in embedded_themes() {
38 match parse_theme_str(id, content, false) {
39 Ok(theme) => {
40 let tokens = resolve(&theme);
41 let css = intent_css_vars(&tokens);
42 map.insert(
43 id.to_string(),
44 ThemeEntry {
45 meta: tokens.meta,
46 css,
47 },
48 );
49 }
50 Err(e) => {
51 // A malformed bundled theme is a build-content bug, not a
52 // runtime input, log and skip rather than abort startup.
53 tracing::error!(theme = id, error = %e, "skipping unparseable bundled theme");
54 }
55 }
56 }
57 map
58 });
59
60 /// Whether `id` names a known built-in theme. Use to validate a creator's
61 /// chosen id before persisting it.
62 pub fn is_valid_theme(id: &str) -> bool {
63 REGISTRY.contains_key(id)
64 }
65
66 /// The rendered primitive-layer CSS for a creator's chosen theme.
67 ///
68 /// Falls back to the default theme when the choice is `None` or names a theme
69 /// that no longer exists (e.g. a bundled theme was removed after a creator
70 /// picked it). Returns `""` only if even the default is missing, which would be
71 /// a build-content bug; the page then renders with the stylesheet's own `:root`.
72 pub fn theme_css(id: Option<&str>) -> &'static str {
73 let chosen = id
74 .filter(|i| REGISTRY.contains_key(*i))
75 .unwrap_or(DEFAULT_THEME_ID);
76 REGISTRY
77 .get(chosen)
78 .or_else(|| REGISTRY.get(DEFAULT_THEME_ID))
79 .map_or("", |e| e.css.as_str())
80 }
81
82 /// All built-in themes, sorted by display name, for rendering a theme picker.
83 /// The currently-selected id (or the default) is the caller's concern.
84 pub fn list_themes() -> Vec<&'static ThemeMeta> {
85 let mut themes: Vec<&ThemeMeta> = REGISTRY.values().map(|e| &e.meta).collect();
86 themes.sort_by(|a, b| a.name.cmp(&b.name));
87 themes
88 }
89
90 /// Validate and normalize a submitted theme id.
91 ///
92 /// Trims, treats absent/empty as "clear to the platform default" (`Ok(None)`),
93 /// accepts a known built-in (`Ok(Some(id))`), and rejects any other non-empty
94 /// value (`Err(the_offending_id)`). Callers map the `Err` to a validation error.
95 pub fn normalize_theme_id(raw: Option<&str>) -> Result<Option<String>, String> {
96 match raw.map(str::trim) {
97 None | Some("") => Ok(None),
98 Some(id) if is_valid_theme(id) => Ok(Some(id.to_string())),
99 Some(id) => Err(id.to_string()),
100 }
101 }
102
103 /// One `<option>` for a theme `<select>`: id, display name, and whether it is
104 /// the creator's current choice.
105 #[derive(Debug, Clone)]
106 pub struct ThemeOption {
107 pub id: String,
108 pub name: String,
109 pub selected: bool,
110 }
111
112 /// Build the picker options, marking the creator's current choice selected.
113 ///
114 /// An unset or no-longer-valid `selected` resolves to [`DEFAULT_THEME_ID`], so
115 /// exactly one option is always marked.
116 pub fn theme_options(selected: Option<&str>) -> Vec<ThemeOption> {
117 let current = selected
118 .filter(|i| is_valid_theme(i))
119 .unwrap_or(DEFAULT_THEME_ID);
120 list_themes()
121 .into_iter()
122 .map(|m| ThemeOption {
123 id: m.id.clone(),
124 name: m.name.clone(),
125 selected: m.id == current,
126 })
127 .collect()
128 }
129
130 #[cfg(test)]
131 mod tests {
132 use super::*;
133
134 #[test]
135 fn default_theme_is_bundled() {
136 assert!(
137 is_valid_theme(DEFAULT_THEME_ID),
138 "makenotwork.toml must be bundled"
139 );
140 }
141
142 #[test]
143 fn default_css_carries_intent_tokens() {
144 let css = theme_css(None);
145 // Intent vocabulary the page's brand aliases derive from.
146 // The two surface values are the re-ramped parchment (makeover 2.4.1):
147 // the original sat 2.8 L* between page and raised, which no bevel can
148 // read. Pinning them here is what makes the style.css :root default and
149 // the injected block provably the same theme rather than two drifting
150 // copies of it.
151 assert!(css.contains("--surface-page: #ddd6c9;"));
152 assert!(css.contains("--content: #3d3530;"));
153 assert!(css.contains("--action: #6c5ce7;"));
154 assert!(css.contains("--border: #b3ab97;"));
155 // Derived states + the scrim are present too.
156 assert!(css.contains("--action-hover: "));
157 assert!(css.contains("--overlay: rgba("));
158 }
159
160 #[test]
161 fn unknown_id_falls_back_to_default() {
162 assert_eq!(theme_css(Some("no-such-theme")), theme_css(None));
163 assert_eq!(theme_css(Some("../etc/passwd")), theme_css(None));
164 }
165
166 #[test]
167 fn known_alternate_theme_differs_from_default() {
168 // Sanity: a real alternate palette renders different CSS.
169 assert!(is_valid_theme("nord"));
170 assert_ne!(theme_css(Some("nord")), theme_css(None));
171 }
172
173 #[test]
174 fn picker_list_is_nonempty_and_includes_default() {
175 let themes = list_themes();
176 assert!(themes.len() > 1);
177 assert!(themes.iter().any(|m| m.id == DEFAULT_THEME_ID));
178 }
179 }
180