//! Tier 0 creator theming. //! //! The platform ships a set of built-in palettes (`shared/themes/*.toml`, the //! same theme-common format the desktop apps emit and consume). A creator may //! pick one for their public profile and for each project; items inherit their //! parent project's choice. `None` everywhere means the platform default, //! [`DEFAULT_THEME_ID`]. //! //! Themes are embedded at compile time so production needs no themes directory //! on disk. Each theme is resolved to its intent tokens and rendered once via //! [`theme_common::intent_css_vars`] and cached; page handlers inject the //! rendered string into the page ``, where it overrides the default //! intent `:root` from `static/style.css` (the brand aliases derive from there, //! so the whole page re-themes). This is the single TOML -> CSS mapping shared //! with GoingsOn and audiofiles. use std::collections::BTreeMap; use std::sync::LazyLock; use include_dir::{Dir, include_dir}; use theme_common::{ThemeMeta, intent_css_vars, parse_theme_str, resolve}; /// The bundled themes, embedded from the shared crate at compile time. static THEMES_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../shared/themes"); /// The platform default theme id — the stock parchment look. Reproduces the /// historical `:root` exactly, so an unset (`None`) choice renders unchanged. pub const DEFAULT_THEME_ID: &str = "makenotwork"; /// A built-in theme: its metadata (for the picker) and its pre-rendered /// intent-layer CSS (for `` injection). pub struct ThemeEntry { pub meta: ThemeMeta, /// `:root { … }` block of resolved intent tokens, ready to inline. pub css: String, } /// Registry of built-in themes keyed by id, sorted by id for a stable picker. static REGISTRY: LazyLock> = LazyLock::new(|| { let mut map = BTreeMap::new(); for file in THEMES_DIR.files() { let path = file.path(); if path.extension().and_then(|e| e.to_str()) != Some("toml") { continue; } let Some(id) = path.file_stem().and_then(|s| s.to_str()) else { continue; }; let Some(content) = file.contents_utf8() else { continue; }; match parse_theme_str(id, content, false) { Ok(theme) => { let tokens = resolve(&theme); let css = intent_css_vars(&tokens); map.insert(id.to_string(), ThemeEntry { meta: tokens.meta, css }); } Err(e) => { // A malformed bundled theme is a build-content bug, not a // runtime input — log and skip rather than abort startup. tracing::error!(theme = id, error = %e, "skipping unparseable bundled theme"); } } } map }); /// Whether `id` names a known built-in theme. Use to validate a creator's /// chosen id before persisting it. pub fn is_valid_theme(id: &str) -> bool { REGISTRY.contains_key(id) } /// The rendered primitive-layer CSS for a creator's chosen theme. /// /// Falls back to the default theme when the choice is `None` or names a theme /// that no longer exists (e.g. a bundled theme was removed after a creator /// picked it). Returns `""` only if even the default is missing, which would be /// a build-content bug; the page then renders with the stylesheet's own `:root`. pub fn theme_css(id: Option<&str>) -> &'static str { let chosen = id .filter(|i| REGISTRY.contains_key(*i)) .unwrap_or(DEFAULT_THEME_ID); REGISTRY .get(chosen) .or_else(|| REGISTRY.get(DEFAULT_THEME_ID)) .map(|e| e.css.as_str()) .unwrap_or("") } /// All built-in themes, sorted by display name, for rendering a theme picker. /// The currently-selected id (or the default) is the caller's concern. pub fn list_themes() -> Vec<&'static ThemeMeta> { let mut themes: Vec<&ThemeMeta> = REGISTRY.values().map(|e| &e.meta).collect(); themes.sort_by(|a, b| a.name.cmp(&b.name)); themes } /// Validate and normalize a submitted theme id. /// /// Trims, treats absent/empty as "clear to the platform default" (`Ok(None)`), /// accepts a known built-in (`Ok(Some(id))`), and rejects any other non-empty /// value (`Err(the_offending_id)`). Callers map the `Err` to a validation error. pub fn normalize_theme_id(raw: Option<&str>) -> Result, String> { match raw.map(str::trim) { None | Some("") => Ok(None), Some(id) if is_valid_theme(id) => Ok(Some(id.to_string())), Some(id) => Err(id.to_string()), } } /// One `