Skip to main content

max / makenotwork

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