//! Tier 0 creator theming. //! //! The platform ships a set of built-in palettes (the themes `makeover` //! embeds, the same set the desktop apps 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 //! [`makeover::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 makeover::{ ThemeMeta, ThemeSelection, embedded_themes, intent_css_vars, parse_theme_str, resolve, }; /// 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 (id, content) in embedded_themes() { 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_or("", |e| e.css.as_str()) } /// 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()), } } /// Validate and normalize a submitted console-theme selection. /// /// Unlike [`normalize_theme_id`] this yields a `makeover::ThemeSelection` /// string rather than an optional id, because "follow the terminal" is a choice /// the row holds and not an absence: a creator who picks it after pinning a /// theme is saying something, and clearing the column back to `NULL` would lose /// the difference between that and never having chosen. /// /// Absent or empty input is [`makeover::FOLLOW`], matching /// `ThemeSelection::parse`. Any other value must name a bundled theme, since an /// id the CLI's embedded set does not carry cannot be honoured there either. pub fn normalize_console_theme(raw: Option<&str>) -> Result { match ThemeSelection::parse(raw) { ThemeSelection::Follow => Ok(makeover::FOLLOW.to_string()), ThemeSelection::Fixed(id) if is_valid_theme(&id) => Ok(id), ThemeSelection::Fixed(id) => Err(id), } } /// One `